Repository navigation
Conversation
TS 24.501 clause 6.4.1.7 c): "If the SMF receives a PDU SESSION ESTABLISHMENT REQUEST message with a PDU session ID identical to the PDU session ID of an existing PDU session and with request type set to "initial request", the SMF shall locally release the existing PDU session and proceed with the PDU session establishment procedure." The SMF does that for a session it holds in memory: the create finds it through ResolveRef and HandlePduSessionContextReplacement releases it. With enableDBStore, a restart leaves the session in the datastore only, ResolveRef misses it, and the new session is created beside it. Every restart followed by the subscriber re-attaching adds one more stored session, which is never released; one subscriber accumulated eighteen on a test cluster. A create whose PDU session ID resolves to no session in memory now lists the sessions stored for the subscriber and PDU session ID and releases every one: documents removed, PFCP sessions deleted at the user planes that acknowledged them, without waiting for the answers. The listing is backed by a non-unique index on the two fields: there are several until the older ones are released, and one whose delete failed stays stored beside its successor. A stored session a lookup has already read into the pool is released as that instance. One the release reads itself is put in the pool first, unless a lookup has put one there meanwhile, which is then the one released: the release removes the pool's entry, and must not remove an instance it did not release. A lookup that reads a stored session puts it in the pool the same way. Releasing a session read back from the datastore differs from releasing one this process created, and the release path now knows which it has: a context is marked as read back where its document is decoded, so none reaches a caller unmarked. - It was never counted in the session gauge, so releasing it no longer decrements the gauge, which then undercounted the live sessions by one, or wrapped when none was counted. - Its data path can name a user plane this SMF holds no association with; that node is skipped instead of dereferenced. Its UE address was allocated by an earlier start of the SMF, and the pool in use may have given it to another UE since. The allocator the context carries back has no pool, so releasing it returns nothing to the pool in use; omec-project#712 made that a no-op instead of a panic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
Releasing a session deleted the seidSmContext document for each of its local SEIDs, and the context document that SEID document named, without checking whose they were. A SEID document is keyed by the SEID alone, so it may name another session than the one being released, and the release then deleted that session's documents, and its in-memory SEID entry with them. The SEID document is now deleted only while it maps to the session being released, and the delete is scoped to that session's reference; the context document deleted with it is the released session's own. The in-memory SEID index drops an entry only if it is held by the session being released. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
…tore Deactivating a session's tunnels frees the PDR, FAR, BAR and QER identifiers its rules hold, returning them to the UPF's generators and removing what the UPF's pools hold under them. A session read back from the datastore carries identifiers an earlier SMF incarnation allocated. This one never allocated them, and may have given the same identifiers to a live session since, so releasing such a session freed the live session's identifiers and removed its rules from the pools, and the next session could be allocated them again. Deactivating the tunnels of any session read back from the datastore now removes its rules from its own PFCP session context only, and leaves the identifiers and the pool entries alone. That deactivation is where every release path frees them, so this holds however the session is released. Its PFCP session on a user plane this SMF holds is still deleted. A policy update that withdraws a rule from such a session had the same effect once the user plane accepted the removal, and now likewise takes the rule out of the session's tunnels and PFCP context and frees none of its identifiers. A rule this start added to the session since is left allocated too: a few identifiers lost until the SMF restarts, against taking a live session's. The revert of an abandoned modification, and a modification that is not sent, still free what they withdraw, which this start built. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Cross-restart teardown needs human validation of remote-session ownership before stale PFCP deletions can safely reach a restarted UPF.
1 open issue
What changed in this PR
This PR extends SMF session replacement to datastore-only sessions after a restart, preventing stale sessions from accumulating when a subscriber re-establishes the same PDU session ID.
Changes:
- Find and release stored sessions before creating their replacement, supported by a compound index.
- Protect live sessions’ SEID records, rule identifiers, and session metrics during restored-session cleanup.
- Add regression tests for stored-session replacement, concurrent lookups, and resource ownership.
| File | Description |
|---|---|
producer/restored_session_test.go |
Tests rule ownership during restored-session policy updates. |
producer/restoration.go |
Registers the restored-session release handler. |
producer/pdu_session.go |
Adds restored-session teardown and skips unavailable UPFs. |
producer/callback.go |
Preserves rule identifiers when withdrawing restored rules. |
fsm/txnFsm.go |
Triggers stored-session cleanup before creating a replacement. |
context/stored_session.go |
Adds stored-session discovery and shared-instance lookup. |
context/stored_session_test.go |
Tests replacement, lookup races, metrics, and resource ownership. |
context/sm_context.go |
Tracks restored contexts and protects SEID entries and metrics. |
context/export_test.go |
Exposes internal state helpers for external tests. |
context/db.go |
Adds the lookup index, restoration flag, and ownership-scoped deletion. |
context/datapath.go |
Avoids freeing restored rule identifiers and dereferencing missing UPFs. |
Review effort: Balanced
Releasing a session read back from the datastore sent a PFCP Session Deletion to every UPF whose RemoteSEID it held. That SEID was issued by whichever incarnation of the UPF was running when an earlier start of the SMF established the session. If the UPF has restarted since, it no longer holds the session, and it may have given the same SEID to another subscriber's session, which the deletion would then remove. The session's PFCP context already records the UPF's recovery timestamp when the UPF acknowledged it (AcknowledgedAtRecovery), but the datastore did not keep it. It is now stored with the PFCP context, and the deletion for a restored session is sent only when it matches the recovery timestamp the SMF holds for the UPF now. Otherwise, including for a document stored before the timestamp was kept, the session is released locally and no deletion is sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
There was a problem hiding this comment.
🟡 Changes recommended
The timestamp-only check can permit session release with a stale RemoteSEID after a UPF restart but before re-association, potentially deleting another subscriber's session.
1 open issue
1 resolved since last review
Review effort: Balanced
…arnation that acknowledged it A heartbeat that reports a UPF restart marks the UPF NotAssociated but leaves the previous incarnation's recovery timestamp held until the re-association replaces it. Releasing a stored session in between found that timestamp equal to the one the session was acknowledged under, and sent a deletion for an SEID the restarted UPF may have given to another session. The check now also requires the UPF to be associated, reading both under UpfLock through UPF.AssociatedIncarnation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Edvin Lindqvist <edvin.lindqvist@forsway.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Destructive PFCP cleanup still requires domain review and deployment-specific validation of UPF session retention across SMF re-association, while recovery-timestamp persistence through MongoDB remains unverified.
No open issues 🎉
1 resolved since last review
Review effort: Balanced

Problem
With
enableDBStore, a subscriber that establishes a PDU session again after an SMF restart ends up with two sessions for the same PDU session ID, and the old one is never released. Every restart followed by a re-attach adds another. On a test cluster one subscriber had accumulated eighteen stored sessions, all for PDU session ID 1, with eighteenseidSmContextandrefToSeidentries beside them. The UPF was answering the SMF's Session Modification Requests for the dead sessions withPFCP session not found; the SMF serving those sessions is the subject of the follow-up below.TS 24.501 clause 6.4.1.7 c) says what the SMF must do: "If the SMF receives a PDU SESSION ESTABLISHMENT REQUEST message with a PDU session ID identical to the PDU session ID of an existing PDU session and with request type set to "initial request", the SMF shall locally release the existing PDU session and proceed with the PDU session establishment procedure."
The SMF does this for a session it holds in memory.
TxnLoadCtxtasksResolveRef(supi, pduSessionID)and, if it finds one,HandlePduSessionContextReplacementreleases it.ResolveRefreadscanonicalRef, which onlyNewSMContextwrites. After a restartcanonicalRefis empty, the previous session exists only in the datastore, and nothing looks there. The SMF reads no request type here. TS 23.502 clause 4.3.2.2.1 step 3 has the AMF sendNsmf_PDUSession_CreateSMContextonly when it holds no association with an SMF for the PDU session ID ("e.g. when Request Type indicates "initial request""), so a create is that case.This is the first of two pull requests. The second is stacked on it, and makes the SMF stop serving a stored session that a later one superseded.
Fix
Five commits.
1. Release a stored session when its PDU session ID is established again. A create whose PDU session ID resolves to nothing in memory now lists the sessions stored for the subscriber and PDU session ID, and releases every one. Each loses its documents, and its PFCP sessions are deleted at the UPFs that acknowledged them, without waiting for the answers, as the in-memory replacement does. Commits 4 and 5 limit those deletions to a UPF that has not restarted since and that the SMF is associated with. If a lookup has already read a stored session into the pool, the release acts on that instance. A lookup that misses the pool while the release is running is handed the instance being released, never a second copy (
LoadOrStore). A lookup that read the document before the release, and stores it after the release completes, can still put the released session back in the pool. That window existed before this PR and is unchanged.The listing is backed by a new non-unique index on
identifierandpduSessionID. It is created inSetupSmfCollectionbeside the existing two throughEnsureIndex, becauseCreateIndexis single-key and unique. It has to be non-unique: there are several sessions for a key until the older ones are released, and one whose delete failed stays stored beside its successor.Every context read back from the datastore is now flagged (
restoredFromStore, set inGetSMContextByRefInDB), and releasing one differs from releasing one this process created:TestReleasingAStoredSessionLeavesItsAddressWithTheUEHoldingItNowpins fix(context): an allocator with no pool refuses the work instead of panicking #712's behaviour on this path.2. Delete a released session's SEID records only where they are its own.
DeleteSmContextInDBBySEIDdeleted theseidSmContextdocument for a local SEID, and the context document it named, without checking whose they were. A SEID document is keyed by the SEID alone, and nothing in the SMF stops a stored session and a live session from naming the same SEID. On a test cluster, two SMF pods two days apart drew SEIDs from the same DRSM chunk. I did not see a stored session's exact SEID handed to a live one. I reproduced that by inserting a stored session carrying the live session's local SEID, and releasing it deleted the live session's documents. The SEID document is now deleted only while it maps to the released session's reference, and the delete is scoped to that reference. The in-memory SEID index drops an entry only if the released session holds it (CompareAndDelete).3. Free no rule identifiers when releasing a stored session. Deactivating a tunnel returns the PDR, FAR, QER and BAR identifiers to the UPF's generators and removes the rules from the UPF's pools. A stored session's identifiers were allocated by an earlier start of the SMF, and this one may have given the same identifiers to a live session since. The rules of every session read back from the datastore are now removed from its own PFCP session context only, on every release path (UE release, AN release, a PFCP report, replacement). Its PFCP session on a UPF this SMF holds is still deleted, within commit 4's limit. The same applies when a policy update withdraws a rule from such a session (
forgetRemovedRules). The revert of an abandoned modification, and a modification that is not sent, still free what they withdraw, because this start built those rules.4. Delete a stored session at its UPF only while that UPF has not restarted. A stored session's RemoteSEID was issued by whichever incarnation of the UPF was running when an earlier start of the SMF established it. A UPF that has restarted since no longer holds the session, and may have given the same SEID to another subscriber's session, which the deletion would remove. The PFCP context already records the UPF's recovery timestamp when the UPF acknowledged the session (
AcknowledgedAtRecovery), but the datastore did not keep it. It is now stored, and the deletion for a session read back from the datastore is sent only when the stored timestamp matches the one the SMF holds for the UPF now and the SMF is associated with that UPF (commit 5). Otherwise the session is released locally and no deletion is sent. That includes documents stored before this commit, which carry no timestamp, and a release that runs before the SMF has associated with the UPF since it started. A session this start created is not affected. On pfcpiface the recovery timestamp is set per association (NewPFCPConn), so it changes exactly when the UPF loses the association and its sessions.5. Require the association as well as the timestamp. A heartbeat that reports a UPF restart marks the UPF
NotAssociatedbut leaves the previous incarnation's recovery timestamp held until the re-association replaces it. A release in between found that timestamp equal to the stored one and sent the deletion. The check now also requires the UPF to be associated, and reads the status and the timestamp together underUpfLock(UPF.AssociatedIncarnation).Commits 2 and 3 were found while testing commit 1, and commits 4 and 5 were added after review. Commit 5 closes a gap in commit 4's check. The defects fixed by commits 2, 3 and 4 are on
mainalready, for any release of a restored session, and commit 1 makes that release common.Testing
Unit tests in
context/stored_session_test.goandproducer/restored_session_test.go, againstmemDB, a new in-memory stand-in for the datastore that answers the filters the SMF uses:TestACreateAfterARestartReleasesTheStoredSession,TestACreateReleasesEveryStoredCopy: a create after a simulated restart removes the stored session's context and SEID documents, and every copy where there are several, and the key then resolves to the new session.TestAStoredSessionAlreadyInThePoolIsReleasedAsThatInstance,TestAStoredSessionPooledWhileItIsReadForReleaseIsTheOneReleased,TestALookupRacingTheReleaseOfAStoredSessionGetsNoSecondCopyTestReleasingAStoredSessionLeavesTheSessionGaugeAlone: the gauge, for stored sessions released directly.TestAStoredSessionALookupReadIsReleasedAsReadBack: the gauge and the PDR, FAR, QER and BAR identifiers, for one a lookup read first.TestReleasingAStoredSessionLeavesItsAddressWithTheUEHoldingItNow: the stored session's address is held by another UE in the running pool. The release reaches fix(context): an allocator with no pool refuses the work instead of panicking #712's no-op and the other UE keeps the address. This pins fix(context): an allocator with no pool refuses the work instead of panicking #712 on this path; it is not a fix of this PR's.TestReleasingAStoredSessionLeavesAnotherSessionsSEIDAlone: a dead and a live session share a SEID; the live one's documents and index entry survive.TestReleasingAStoredSessionDeletesItsPFCPSessionAndFreesNoRuleIdentifiers,TestAWithdrawnRuleOfAStoredSessionLeavesItsIdentifiersToTheLiveSession: a live session holds a PDR, FAR, QER and BAR with the same identifiers as the stored one; all four stay with the live session.TestAStoredSessionIsDeletedOnlyAtTheIncarnationThatAcknowledgedIt: no deletion reaches the UPF when the stored session was acknowledged before the UPF's current recovery timestamp, when the document records none, when neither is known, when the document records one but the SMF has not associated with the UPF since it started, and when a heartbeat has reported the UPF restarted and it has not re-associated yet, and while the association is still being set up; the session is still released.TestTheAcknowledgingIncarnationSurvivesMarshalJSONcovers the second encoder; the datastore writes throughToBsonM, which the deletion tests go through.Each test was shown to fail when the line it guards is reverted on its own: the gauge guard,
CompareAndDelete, the SEID ownership check, the identifier skips (release and policy update), theUPF == nilguards, theLoadOrStorein both read paths, the flag inGetSMContextByRefInDB, theTxnLoadCtxtcall, #712's allocator guard, and commit 4's timestamp comparison, its zero check, treating an unknown held timestamp as a match, the timestamp's encoding in both encoders and its decoding, and commit 5's association check. Dropping the condition that limits commit 4 to sessions read back from the datastore fails two existing rollback tests, which expect the deletion for a session this start created. The SEID check is two guards, the read and the scoped delete, and the test fails only with both removed. The read check is redundant with the scoped delete, and no test covers the window between them. No test covers the revert path's freeing either; the not-sent path is covered.On a lab cluster (SMF with
enableDBStore, UERANSIM) I restarted the SMF three times and re-attached the UE after each restart.smf.data.smContextandseidSmContextheld exactly one document for the subscriber each time, and the first cycle released the six documents left from before the fix. That ran on an earlier build of this PR and the follow-up, which did not yet include commits 2 to 5. The document count after each re-attach is what this PR is responsible for. The listing's filter matched the stored documents on that cluster's MongoDB, andexplain()showed the index in use. The unit tests cannot show this, becausememDBcompares numbers by value. Commit 4's stored timestamp has not been exercised against MongoDB; the unit tests round-trip it through the same JSON encoding the datastore write uses.pre-commit run --all-filesis clean on macOS (darwin/arm64, Go 1.26.10), with no hook skipped. That covers gitleaks, gci, staticcheck,go test -race ./... -count=20, golangci-lint v2.13.2, yamlfmt and reuse.go buildandgo vetare clean too. Each commit builds and vets on its own.Trade-offs
enableDBStoreonly. If the listing fails, the create proceeds without releasing, and stored duplicates for that key survive. A hung datastore delays the create by up to 30 s (the listing's timeout) before it proceeds. Several stored copies are released one after another before the new session is created.smf.data.refToSeidis write-only today and still accumulates one entry per release. Not touched here.Follow-up
A second PR, stacked on this one, keeps a superseded stored session from being served at all. Today any request naming it, whether an AMF update, a PCF notification, an OAM read or a PFCP message for its SEID, restores it as live.
🤖 Generated with Claude Code