Repository navigation
Prevent user code from changing internal state: single writer per state, read-only interfaces and factories #609
Description
Some public methods change internal state that only the framework should change. If user code calls them, the simulation can get out of sync. This issue proposes how to fix that without breaking the feature-driven package structure and without creating new objects.
Problem
- Java has no internal scope. Package-private methods can only be called from the same package, but most of these methods are called by framework classes in other packages (e.g. the placement policies change VM state, and the Host changes Datacenter state). So they are public.
- Concrete types expose them without any cast. Common user code such as
final var vm = new VmSimple(new CoreCapacity(2, 1000))holds aVmSimple, so every public method ofVmAbstractis available, includingsetHost()andsetCreated(). Hiding methods behind interfaces doesn't help in this case. - State is changed in several places. Even for framework code, each piece of state can be changed by different classes, so each caller must remember to update everything that depends on it. That causes drift between related values. Some bugs found recently came from that:
- The allocator reserved Host RAM and another call set the VM capacity, separately. Use placement, allocation, provisioning and utilization consistently (#588) #606 joined both in
Host.provisionResourceForVm(). Host.getRamUtilization()returned the allocated amount kept by another class instead of what the VMs use (fixed in Use placement, allocation, provisioning and utilization consistently (#588) #606).- The Host busy cores drifted when several places updated them (fixed in Fixes #592: replace List<Pe> by a Processor object that just stores counts for memory footprint reduction #595 by computing them in a single place).
- The Host cached a list of its allocators that could become stale if an allocator was changed during the simulation (removed in Make the Host interface smaller and lazily create its state history #608).
- The allocator reserved Host RAM and another call set the VM capacity, separately. Use placement, allocation, provisioning and utilization consistently (#588) #606 joined both in
Requirements
- No new objects per Host, VM or Cloudlet, since that increases memory use.
- If a user casts an object to an implementation class on purpose, that's acceptable: they take responsibility.
- A reference of a concrete type (
new VmSimple(...)) must not expose methods that can break the simulation. - Keep the feature-driven packages.
Options ruled out
| Option | Why |
|---|---|
Move all implementations to an internal package |
Breaks the feature-driven package structure. It also only hides methods from users: framework classes could still change each other's state from many places, so the drift bugs would remain. |
| Convert the framework to Kotlin | Kotlin's internal doesn't protect Java callers, and the cost is very high (see below). |
| Several modules with qualified exports | Still based on packages, and doesn't work for users on the classpath. |
| Capability objects or sealed "key" parameters | Create extra objects or parameters, and aren't needed since casting is acceptable. |
An @Internal annotation alone |
Only an IDE warning: it doesn't prevent anything. |
Why Kotlin was ruled out
Kotlin has an internal visibility modifier (visible within the same module), which is what Java is missing. But it wouldn't solve the problem for our users:
internalis only checked by the Kotlin compiler. In the bytecode,internalclasses are public, andinternalmembers are public methods with a changed name (e.g.setHost$cloudsimplus). Java code can still call them:javacdoesn't block it.- Almost all users write Java. CloudSim Plus users are mostly researchers and students who learned Java, and the papers, courses and tutorials that use it are in Java. So the protection would only reach the few users who write their simulations in Kotlin. Converting the examples to Kotlin as well wouldn't change that: it would only remove the main learning material for Java users.
- The API would become worse for Java users. Constants and static methods would be in companion objects (e.g.
Vm.Companion.getNULL()instead ofVm.NULL), unless every one gets@JvmField/@JvmStatic. Methods with default parameter values need@JvmOverloads. Users would also get thekotlin-stdlibdependency (about 1.7 MB). - The cost is very high. The framework has about 360 source and 85 test files (130 of them use Lombok, which doesn't work with Kotlin), and the examples, testbeds, automation and benchmarks projects have hundreds more. Every simulation result would need to be compared with the Java version to show nothing changed.
- Fewer people could contribute, since fewer people in the research community write Kotlin.
- It wouldn't fix the drift bugs. Like an
internalpackage, it only changes who can see the methods. Framework classes could still change each other's state from many places.
The options below solve the problem in Java, for every user, without these costs.
Proposal
Option 1: each piece of state has a single writer, its owner (main fix)
No method that can break the simulation stays public in the *Abstract/*Simple classes. Each one is either:
- removed, moving the work to the object that owns the state, through a public operation that keeps everything in sync (like
Host.provisionResourceForVm()in Use placement, allocation, provisioning and utilization consistently (#588) #606); or - made package-private, when its callers are in the same package.
After that, it doesn't matter which type a variable has: new VmSimple(...) or a cast only give access to safe methods. This also removes the drift, since related values are always changed together, in one place.
| Class | Method | What it changes | Called by | Proposal |
|---|---|---|---|---|
VmAbstract |
setHost(host) |
where the VM is placed | HostAbstract, NetworkVm, VmPlacementPolicyAbstract |
Only Host.createVm()/destroyVm() set it. The other callers go through the Host |
VmAbstract |
setCreated(boolean) |
if the VM was created | HostAbstract, placement policies |
Same as setHost() |
VmAbstract |
setMipsShares(requested, allocated) |
the VM CPU shares | VmSchedulerAbstract |
Changed only inside the VmScheduler operations that already allocate and resize cores |
VmAbstract |
addExpectedFreeCores(), removeExpectedFreeCores() |
the broker core accounting | DatacenterBrokerAbstract |
Changed by the operations that cause them (submitting a Cloudlet to a VM, the Cloudlet finishing) |
VmAbstract |
setGroup(group) |
the VM group | VmGroup |
Set when a VM is added to a VmGroup |
VmAbstract |
updateMigrationStartListeners(), updateMigrationFinishListeners(), notifyOnUpdateProcessingListeners() |
fires listeners | HostAbstract, DatacenterSimple, CloudletExecution |
Fired by the operation that starts/finishes the migration or the processing update |
HostAbstract |
destroyVm(), destroyAllVms(), destroyTemporaryVm() |
releases resources and removes VMs | placement policies | Host operations that keep the Host, the VMs and the Datacenter in sync, called through the Host interface |
HostAbstract |
processActivation(boolean) |
the Host power state | DatacenterSimple |
Part of the Host power on/off operation |
DatacenterSimple |
updateActiveHostsNumber(), updatePoweringOnHostsNumber() |
the Datacenter counters | HostAbstract |
The Datacenter updates its own counters when a Host power state changes |
CustomerEntityAbstract |
setCreationTime() |
the creation time of Cloudlets and VMs | DatacenterSimple |
Set by the operation that creates the entity in the Datacenter |
Option 2: internal methods move out of the interfaces users see
These methods are in the interfaces, so every user sees them, but only the framework calls them (no calls in the examples, testbeds, automation or benchmarks projects):
| Interface | Methods |
|---|---|
Vm |
addStateHistoryEntry, notifyOnCreationFailureListeners, notifyOnHostPlacementListeners, notifyOnHostRemovalListeners, setInMigration, updateProcessing |
Host |
addMigratingInVm, removeMigratingInVm, addVmMigratingOut, removeVmMigratingOut, reallocateMigratingInVms, createTemporaryVm, setSimulation, setDatacenter, updateProcessing |
Cloudlet |
addFinishedLengthSoFar, notifyOnUpdateProcessingListeners, registerArrivalInDatacenter, setStatus, setBrokerArrivalTime, setLastTriedDatacenter |
Datacenter |
addBrokerWaitingHostPowerOn |
Each one is first checked against option 1 (most of them should become part of an operation of their owner). The ones left move to the *Abstract class, and the interface is what users see:
They follow the same rules #608 used for the Host allocators:
- Private fields use the
*Abstracttype, so the framework calls these methods without casts (e.g.HostAbstractkeeps aResourceAllocatorAbstract). - Parameters and getters use the interface everywhere (e.g.
setRamAllocator(ResourceAllocator)andgetRamAllocator()returningResourceAllocator), so users can program to the interface and declare their objects asResourceAllocator allocator = new ResourceAllocatorSimple(). - The interface is cast in a single place, which must be safe: the setter (or the constructor, through the setter) casts the received interface to the
*Abstracttype. To make sure the cast never fails, the sealed interface only permits the*Abstractclass, and theNullclass extends it too (overriding the methods that change state with methods that do nothing, so the sharedNULLobject never changes). That's howResourceAllocatorworks since Make the Host interface smaller and lazily create its state history #608. - When the
*Abstracttype is only needed to call one method once (e.g. linking an object to its owner), aninstanceofcheck is enough and the field can keep the interface type, asDatacenterSimple.setCharacteristics()does since Make the Host interface smaller and lazily create its state history #608. - Where a framework class only has a parameter or a list element, it casts, as many classes already do.
Option 3: users get the interface type when they create objects
This is a fallback, only for implementation classes that still have unsafe public methods after options 1 and 2 (probably Vm, Host, Cloudlet and Datacenter at most). Each of those implementations gets static of() factory methods that return the interface type, and its constructors become protected:
public class VmSimple extends VmAbstract {
/// Creates a VM. This constructor is only for subclasses:
/// use [#of(CoreCapacity)] to create a VM.
protected VmSimple(final CoreCapacity capacity) { ... }
/// Creates a VM.
/// @see #VmSimple(CoreCapacity)
public static Vm of(final CoreCapacity capacity) {
return new VmSimple(capacity);
}
}
// User code
final var vm = VmSimple.of(new CoreCapacity(2, 1000)); // type: Vm- The factories are in each implementation, not in the interfaces, so they read naturally when there are several implementations (e.g.
VmPlacementPolicyFirstFit.of(),VmPlacementPolicyBestFit.of()), and the interfaces don't get one factory per implementation. new VmSimple(...)no longer compiles outside its package, so users don't get a reference of the concrete type unless they cast, which is acceptable.- Subclasses (
class MyVm extends VmSimple) still callsuper(...)and take responsibility for the public methods they inherit, as users who cast do. - There is one
of()overload for each public constructor. - Each protected constructor documents its factory: its Javadoc says it's only for subclasses and links to the matching
of()(as in the example above), so users who look for a constructor find what to call instead. The parameters are documented in the constructor, and the factory links back to it. - Static methods aren't overridden: if a subclass doesn't declare its own
of(), calling it through the subclass name creates an object of the parent class. So every framework subclass of a class withof()must declare its own (an ArchUnit test can check that). - If an implementation has configuration methods that aren't in its interface (e.g.
UtilizationModelDynamic.setUtilizationUpdateFunction()), itsof()would have to return the concrete type, which brings back the unsafe methods. That's one more reason to only use option 3 for classes whose unsafe methods can't be fixed with options 1 and 2. - Framework classes in other packages use the factories:
VmBuilderandVmPlacementPolicyMigrationAbstract(VmSimple),HostBuilder(HostSimple),CloudletBuilderandSwfWorkloadFileReader(CloudletSimple),DatacenterBuilderandExperiment(DatacenterSimple).
Factories in the interfaces (e.g. Vm.of(), ResourceAllocator.simple()) were ruled out: an interface with several implementations would need one factory for each, and they would have the same configuration methods problem.
Option 3 alone isn't enough: subclasses still see every public method. Options 1 and 2 are what make the methods safe, so option 3 comes last, if it's needed at all.
Implementation order
- Option 1 for Host and VM placement (
setHost,setCreated,destroyVm,destroyAllVms,destroyTemporaryVm), where misuse breaks the most. - Option 1 for the remaining
VmAbstract,HostAbstract,DatacenterSimpleandCustomerEntityAbstractmethods. - Option 2 for the
Vm,Host,CloudletandDatacenterinterface methods, one PR per interface. - Option 3, only for the classes that still need it, in a major version with a migration table in the CHANGELOG, since it changes how users create those objects.
Impact
- Performance: none of the options creates objects. Moving work to the owner may even remove repeated updates. Each PR must check the processing update chain (Datacenter → Host → Vm → CloudletScheduler), as
CLAUDE.mdasks. - Breaking changes: options 1 and 2 remove or move public methods that users shouldn't call. Option 3 changes about 380 object creations in the examples, testbeds, automation and benchmarks projects (
new VmSimple: 99,new HostSimple: 91,new CloudletSimple: 101,new DatacenterSimple: 89), which is a mechanical change. Existing subclasses keep working.
Engineering principles
| Principle | How it applies |
|---|---|
| Encapsulation / Information Hiding | State is only changed by its owner, through operations that keep related values in sync. Users only see safe methods. |
| Tell, Don't Ask | Callers ask the owner to do an operation (e.g. host.createVm(vm)) instead of changing its state step by step (vm.setHost(host), vm.setCreated(true)). |
| Single writer / Single source of truth | Each piece of state has one class that changes it, which prevents the drift bugs listed above. |
| Interface Segregation Principle (ISP) | The interfaces users see don't have methods only the framework needs. |
| Dependency Inversion Principle (DIP) | With the factories, user code depends on the interfaces instead of the implementation classes. |
🤖 Generated with Claude Code
Activity
Make the Host the only class that places VMs (#609)
Move methods only the framework calls out of the Vm interface (#609)
Move methods only the framework calls out of the Host interface (#609)
Move methods only the framework calls out of the Cloudlet and Custome…
Move addBrokerWaitingHostPowerOn out of the Datacenter interface (#609)
Create Vms, Hosts, Cloudlets and Datacenters with of() factories that…
Fix a doc link to Host.updateProcessing(), which is now only in HostA…
Declare Host.setId() and Datacenter.setVmPlacementPolicy() in the int…
Prevent user code from changing internal state: single writers, inter…