UNiagaraDataChannelReader::Cleanup() is not exported (missing NIAGARA_API), so external code cannot release a held Read reference — permanently pins Map-based (GameplayBurst/Island) channel entries and blocks the shared system from ever respawning

Summary

UNiagaraDataChannelHandler lazily creates and permanently caches a single UNiagaraDataChannelReader per channel
handler (NiagaraDataChannelHandler.cpp:100-108). Every time game code calls BeginRead/InitAccess on that reader,
Reader::Data (FNiagaraDataChannelDataPtr, NiagaraDataChannelAccessor.h:25) is set to point at the target bucket’s
FNDCMapEntry::Data (NiagaraDataChannelAccessor.cpp:57). This reference is never released automatically — it only
changes when the same Reader is used to BeginRead/InitAccess a different location, or when Cleanup() is called
(NiagaraDataChannelAccessor.cpp:36-38, simply Data = nullptr;).

However, Cleanup() is declared in a MinimalAPI class (UCLASS(BlueprintType, MinimalAPI),
NiagaraDataChannelAccessor.h:18) without the NIAGARA_API macro (NiagaraDataChannelAccessor.h:33 for Reader, :113
for Writer), while sibling methods like InitAccess/BeginRead/BeginWrite are exported. This makes Cleanup()
unusable from any external module — calling it produces LNK2019: unresolved external symbol.

The entry-retirement check in FNDCMapEntryBase::BeginFrame (NiagaraDataChannel_Map.cpp:318-344) checks bInUse =
Data.IsUnique() == false; first (line 328), before even checking whether the spawned components are complete. If
a Reader from any external caller still holds a reference to that bucket’s Data, IsUnique() is permanently false,
so the entry is never retired — even after its spawned Niagara System naturally completes. Every subsequent
write to that same grid cell then reuses the dead entry via FindOrAddEntry (NiagaraDataChannel_Map.cpp:219-223,
returns the existing entry without calling Init()/SpawnSystem() again), so the shared system never
spawns/visualizes again for that bucket, for the remaining lifetime of the World.

Because a caller has no exported way to explicitly release the reference, and because with a coarse grid cell
size (e.g. a large GameplayBurst cell) many/all writes route to the same single bucket, a single Read call
anywhere against that bucket can permanently and silently break all future spawns for it.

What Type of Bug are you experiencing?

AI

Steps to Reproduce

  1. Create a Niagara Data Channel asset. Set Channel Type to GameplayBurst (or any Map-based channel, e.g. Island) with a SystemToSpawn NS that completes quickly with no particles left (default “Complete If Unused” behavior), and a grid cell size large enough that all test locations fall in one bucket.
  2. From Blueprint or C++, periodically Write To Niagara Data Channel (With Context) at a fixed world location — this causes the bucket to spawn (or keep using) the shared NS.
  3. After each write, also call Read From Niagara Data Channel (With Context) once at the same location (e.g. to read back a value) via GetDataChannelReader()/BeginRead().
  4. Stop writing for a few seconds and let the spawned NS naturally reach Complete (0 particles + “Complete If Unused”).
  5. Resume writing to the same location.

Expected Result

The bucket entry retires once its spawned system completes (SpawnedComponents all IsComplete()),
so the next write re-triggers Init()/SpawnSystem() and the shared NS visibly respawns.

Observed Result

The shared NS never spawns/appears again for that bucket. Niagara Debugger shows zero spawned
systems for it going forward, even though writes keep succeeding with no errors, because Data.IsUnique() never
becomes true again (the cached Reader::Data reference from step 3 is never released).

Affects Versions

5.8

Platform(s)

Windows