Skip to content

Prevent user code from changing internal state: single writer per state, read-only interfaces and factories #609

Closed

Description

@manoelcampos

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

  1. 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.
  2. Concrete types expose them without any cast. Common user code such as final var vm = new VmSimple(new CoreCapacity(2, 1000)) holds a VmSimple, so every public method of VmAbstract is available, including setHost() and setCreated(). Hiding methods behind interfaces doesn't help in this case.
  3. 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:

Requirements

  1. No new objects per Host, VM or Cloudlet, since that increases memory use.
  2. If a user casts an object to an implementation class on purpose, that's acceptable: they take responsibility.
  3. A reference of a concrete type (new VmSimple(...)) must not expose methods that can break the simulation.
  4. 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:

  1. internal is only checked by the Kotlin compiler. In the bytecode, internal classes are public, and internal members are public methods with a changed name (e.g. setHost$cloudsimplus). Java code can still call them: javac doesn't block it.
  2. 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.
  3. The API would become worse for Java users. Constants and static methods would be in companion objects (e.g. Vm.Companion.getNULL() instead of Vm.NULL), unless every one gets @JvmField/@JvmStatic. Methods with default parameter values need @JvmOverloads. Users would also get the kotlin-stdlib dependency (about 1.7 MB).
  4. 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.
  5. Fewer people could contribute, since fewer people in the research community write Kotlin.
  6. It wouldn't fix the drift bugs. Like an internal package, 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:

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:

  1. Private fields use the *Abstract type, so the framework calls these methods without casts (e.g. HostAbstract keeps a ResourceAllocatorAbstract).
  2. Parameters and getters use the interface everywhere (e.g. setRamAllocator(ResourceAllocator) and getRamAllocator() returning ResourceAllocator), so users can program to the interface and declare their objects as ResourceAllocator allocator = new ResourceAllocatorSimple().
  3. 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 *Abstract type. To make sure the cast never fails, the sealed interface only permits the *Abstract class, and the Null class extends it too (overriding the methods that change state with methods that do nothing, so the shared NULL object never changes). That's how ResourceAllocator works since Make the Host interface smaller and lazily create its state history #608.
  4. When the *Abstract type is only needed to call one method once (e.g. linking an object to its owner), an instanceof check is enough and the field can keep the interface type, as DatacenterSimple.setCharacteristics() does since Make the Host interface smaller and lazily create its state history #608.
  5. 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 call super(...) 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 with of() 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()), its of() 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: VmBuilder and VmPlacementPolicyMigrationAbstract (VmSimple), HostBuilder (HostSimple), CloudletBuilder and SwfWorkloadFileReader (CloudletSimple), DatacenterBuilder and Experiment (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

  1. Option 1 for Host and VM placement (setHost, setCreated, destroyVm, destroyAllVms, destroyTemporaryVm), where misuse breaks the most.
  2. Option 1 for the remaining VmAbstract, HostAbstract, DatacenterSimple and CustomerEntityAbstract methods.
  3. Option 2 for the Vm, Host, Cloudlet and Datacenter interface methods, one PR per interface.
  4. 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.md asks.
  • 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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions