Commit cd7dbced authored by Christos Tranoris's avatar Christos Tranoris
Browse files

Goal: report simulated create/operate/teardown cost (seconds, Wh,

units) through OSOM tentative planning
parent 2da58311
Loading
Loading
Loading
Loading
+79 −0
Changes for docs/20260716_1430_report-lifecycle-estimates.md: 79 added lines, 0 removed lines.
Original line number Diff line number Diff line
# Report simulated CREATION / OPERATION / TEARDOWN estimates during planning

**Date:** 2026-07-16
**Companion change:** OSOM `docs/20260716_1430_planning-lifecycle-estimates.md`

## Why

Under OSOM tentative planning this controller already reported *what* it would deploy, via the
`PLANNING.RESOURCE.CREATEORUPDATE` callback. It said nothing about what deploying it would cost. OSOM
now understands a per-resource lifecycle estimate, so this example controller demonstrates the
producing side of that contract.

## What it reports

Units are fixed by the contract and not transmitted: **seconds**, **Watt-hours (Wh)**, **dimensionless
cost units**.

On each planning callback, as JMS headers:

```
org.etsi.osl.estimate.<creation|operation|teardown>.durationSeconds
org.etsi.osl.estimate.<creation|operation|teardown>.energy
org.etsi.osl.estimate.<creation|operation|teardown>.cost
```

One phase per callback — OSOM merges them onto the same tentative resource, so the phases accumulate
rather than overwrite:

| Handler | Callback status | Phase reported |
|---|---|---|
| `createResource` (sync) | `RESERVED` | creation |
| `createResource` (after `PLANNING_SIMULATED_DEPLOY_DELAY_MS`) | `AVAILABLE` | operation |
| `updateResource` | `AVAILABLE` | operation |
| `deleteResource` | `UNKNOWN` | teardown |

## The operation window

OSOM sends `org.etsi.osl.operationDurationSeconds` on the planning request (derived from the service
order's `requestedCompletionDate − requestedStartDate`, or its configured default). The OPERATION phase
is that window multiplied by the spec's steady-state power draw and cost rate.

**When the header is absent, no operation estimate is reported** and the controller logs at INFO. It
deliberately does not invent a default: OSOM owns that decision and logs an assumption when it applies
one, so a fallback here would silently mask a missing header and report a confident figure for a
duration nobody chose.

## The cost model

New `ResourceEstimator` — stateless, static, side-effect free. Per-spec constants (creation and
teardown seconds/Wh/cost, plus operating watts and cost-per-hour) in a small map keyed by spec name.

**Its numbers are invented.** They exist to exercise the contract end to end; a controller wrapping real
infrastructure would derive them from the hardware profile, the deployment plan, and a tariff.

It is a separate class on purpose: `ResourceA`/`ResourceB` keep all their spec state in `static` fields
(shared across every instance and every concurrent message) and carry an `@Autowired static
CatalogClient` that Spring never populates. Adding per-instance estimate state there would have deepened
an existing problem.

## New `/PLANNING` routes for UPDATE and DELETE

`PartnerRouteBuillder` now consumes `UPDATE/<cat>/<ver>/PLANNING` and `DELETE/<cat>/<ver>/PLANNING`,
mirroring the `CREATE` route that has existed since `20260704_0009`.

This closes a real gap. OSOM's day-2 planning simulation previously sent simulated UPDATE/DELETE to the
**production** queues, tagged only with `X-OSL-SIMULATION`. The `updateResource`/`deleteResource`
simulation branches were written and correct but effectively unreachable through the intended path, and
a controller that ignored the header would have executed a dry-run for real. OSOM now routes dry-runs to
`/PLANNING`; these routes are the matching consumer.

Visible effect: `UNDER SIMULATION DELETE` now actually appears in the log during a planning tear-down.
It never did before.

## Files

- `api/ResourceEstimator.java` (new)
- `api/ResourceRepoService.java` — phase threading, `putPhase`, `operationDurationFrom`, header constants
- `api/PartnerRouteBuillder.java` — `EVENT_UPDATE_PLANNING`, `EVENT_DELETE_PLANNING` routes
- `application.yml` — contract documentation comment
+1 −0
Changes for docs/index.md: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@

| Doc | Description |
|---|---|
| [20260716_1430_report-lifecycle-estimates.md](20260716_1430_report-lifecycle-estimates.md) | Report simulated CREATION / OPERATION / TEARDOWN estimates (seconds / Watt-hours / dimensionless units) back to OSOM as JMS headers on the planning callback, one phase per callback. New stateless `ResourceEstimator` holds the per-spec cost model (invented numbers — it demonstrates the contract, it models nothing real); the OPERATION phase is sized by the `org.etsi.osl.operationDurationSeconds` window OSOM sends, and is omitted entirely when that header is absent rather than defaulted locally. Also adds `UPDATE/…/PLANNING` and `DELETE/…/PLANNING` routes, so OSOM's day-2 dry-runs stop landing on the production queues — `UNDER SIMULATION DELETE` now actually appears in the log. |
| [20260707_0044_dedup-resourcereposervice.md](20260707_0044_dedup-resourcereposervice.md) | Refactor `ResourceRepoService`: extract `resourceIdFromHeaders` + `applyResourceLogic` helpers and header-key constants to remove three duplicated boilerplate blocks (no behavior change). |
| [20260704_0023_send-simulated-resource-to-planning-queue.md](20260704_0023_send-simulated-resource-to-planning-queue.md) | During simulation, report the controller's computed resource to OSOM's `PLANNING.RESOURCE.CREATEORUPDATE` callback queue instead of only building a local echo. |
| [20260704_0009_fix-planning-simulation-support.md](20260704_0009_fix-planning-simulation-support.md) | Add a `/PLANNING` queue consumer and skip real catalog writes/background threads when the `X-OSL-SIMULATION` header is present, so this example controller works correctly under OSOM planning/simulation. |
+22 −0
Changes for src/main/java/org/etsi/osl/example/gc/api/PartnerRouteBuillder.java: 22 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -54,8 +54,14 @@ public class PartnerRouteBuillder extends RouteBuilder {

    String EVENT_UPDATE = "jms:queue:UPDATE/"+compcategory+"/" + compversion;

    // Day-2 dry-runs (planning tear-down / characteristic-change simulation) arrive on the "/PLANNING"
    // variants, mirroring CREATE, so a simulated request can never land on the production queue.
    String EVENT_UPDATE_PLANNING = EVENT_UPDATE + "/PLANNING";

    String EVENT_DELETE = "jms:queue:DELETE/"+compcategory+"/" + compversion;

    String EVENT_DELETE_PLANNING = EVENT_DELETE + "/PLANNING";

    from(EVENT_CREATE)
        .log(LoggingLevel.INFO, log, EVENT_CREATE + " message received!")
        .to("log:DEBUG?showBody=true&showHeaders=true").unmarshal()
@@ -80,6 +86,14 @@ public class PartnerRouteBuillder extends RouteBuilder {
        .marshal().json( JsonLibrary.Jackson)
        .convertBodyTo( String.class );;

    from(EVENT_UPDATE_PLANNING)
        .log(LoggingLevel.INFO, log, EVENT_UPDATE_PLANNING + " message received!")
        .to("log:DEBUG?showBody=true&showHeaders=true").unmarshal()
        .json(JsonLibrary.Jackson, ResourceUpdate.class, true)
        .bean( resourceRepoService, "updateResource( ${headers},${body} )")
        .marshal().json( JsonLibrary.Jackson)
        .convertBodyTo( String.class );

    from(EVENT_DELETE)
        .log(LoggingLevel.INFO, log, EVENT_DELETE + " message received!")
        .to("log:DEBUG?showBody=true&showHeaders=true").unmarshal()
@@ -88,6 +102,14 @@ public class PartnerRouteBuillder extends RouteBuilder {
        .marshal().json( JsonLibrary.Jackson)
        .convertBodyTo( String.class );;

    from(EVENT_DELETE_PLANNING)
        .log(LoggingLevel.INFO, log, EVENT_DELETE_PLANNING + " message received!")
        .to("log:DEBUG?showBody=true&showHeaders=true").unmarshal()
        .json(JsonLibrary.Jackson, ResourceUpdate.class, true)
        .bean( resourceRepoService, "deleteResource( ${headers}, ${body} )")
        .marshal().json( JsonLibrary.Jackson)
        .convertBodyTo( String.class );


  }

+69 −0
Changes for src/main/java/org/etsi/osl/example/gc/api/ResourceEstimator.java: 69 added lines, 0 removed lines.
Original line number Diff line number Diff line
package org.etsi.osl.example.gc.api;

import java.util.Map;

/**
 * Illustrative cost model for this example controller's resource specs, used only on the OSOM planning
 * (dry-run) path to report simulated CREATION / OPERATION / TEARDOWN estimates back on the
 * {@code PLANNING.RESOURCE.CREATEORUPDATE} callback.
 *
 * <p><b>These numbers are invented.</b> They exist to demonstrate the estimate contract end to end, not
 * to model anything real. A controller wrapping actual infrastructure would derive them from the
 * hardware profile, the deployment plan, and a tariff — do not read the figures below as a reference.
 *
 * <p>The model is deliberately trivial: each spec has fixed creation and teardown costs, plus a
 * steady-state power draw and cost rate; the OPERATION phase is those rates multiplied by the window
 * OSOM supplies in the {@code org.etsi.osl.operationDurationSeconds} request header.
 *
 * <p>Units, fixed by the header contract and never transmitted: seconds, Watt-hours (Wh), and
 * dimensionless cost units.
 *
 * <p>Stateless and side-effect free by design — unlike {@link ResourceA}/{@link ResourceB}, whose spec
 * state is static and shared.
 */
public final class ResourceEstimator {

    private ResourceEstimator() {}

    /** One lifecycle phase; any component may be null, meaning "not estimated". */
    public record Phase(Double durationSeconds, Double energyWh, Double cost) {}

    /** Per-spec constants backing the model. */
    private record Profile(double creationSeconds, double creationWh, double creationCost,
                           double operationWatts, double operationCostPerHour,
                           double teardownSeconds, double teardownWh, double teardownCost) {}

    private static final Map<String, Profile> PROFILES = Map.of(
            ResourceA.RESOURCE_NAME, new Profile(45, 12, 0.50, 8.0, 0.02, 15, 3, 0.10),
            ResourceB.RESOURCE_NAME, new Profile(90, 30, 1.20, 20.0, 0.05, 30, 6, 0.20));

    /** CREATION estimate for a spec; null when the spec is unknown to this controller. */
    public static Phase creation(String specName) {
        Profile p = PROFILES.get(specName);
        return p == null ? null : new Phase(p.creationSeconds(), p.creationWh(), p.creationCost());
    }

    /**
     * OPERATION estimate over the given window: power x hours for energy, rate x hours for cost. The
     * reported duration is the window itself, echoing back what OSOM asked us to size against.
     *
     * @param operationDurationSeconds the window from {@code org.etsi.osl.operationDurationSeconds}
     * @return null when the spec is unknown to this controller
     */
    public static Phase operation(String specName, long operationDurationSeconds) {
        Profile p = PROFILES.get(specName);
        if (p == null) {
            return null;
        }
        double hours = operationDurationSeconds / 3600.0;
        return new Phase((double) operationDurationSeconds,
                p.operationWatts() * hours,
                p.operationCostPerHour() * hours);
    }

    /** TEARDOWN estimate for a spec; null when the spec is unknown to this controller. */
    public static Phase teardown(String specName) {
        Profile p = PROFILES.get(specName);
        return p == null ? null : new Phase(p.teardownSeconds(), p.teardownWh(), p.teardownCost());
    }
}
+85 −10
Changes for src/main/java/org/etsi/osl/example/gc/api/ResourceRepoService.java: 85 added lines, 10 removed lines.
Original line number Diff line number Diff line
@@ -30,6 +30,15 @@ public class ResourceRepoService {
  private static final String HDR_SERVICE_ID       = "org.etsi.osl.serviceId";
  private static final String HDR_SERVICE_ORDER_ID = "org.etsi.osl.serviceOrderId";

  /** Inbound on planning requests: how long OSOM expects the resource to be in service (seconds). */
  private static final String HDR_OPERATION_DURATION_SECONDS = "org.etsi.osl.operationDurationSeconds";

  /**
   * Outbound on the planning callback: {@code <prefix><phase>.{durationSeconds|energy|cost}}, carrying
   * this controller's simulated cost for the phase. Units: seconds, Watt-hours, dimensionless units.
   */
  private static final String HDR_EST_PREFIX = "org.etsi.osl.estimate.";

  @Autowired
  CatalogClient aCatalogClient;

@@ -86,7 +95,7 @@ public class ResourceRepoService {
      // PLANNING.RESOURCE.CREATEORUPDATE callback queue.
      applyResourceLogic(specName, resourceRequested, resourceid, true);

      Resource reported = sendPlanningResourceCallback(headers, resourceid, resourceRequested);
      Resource reported = sendPlanningResourceCallback(headers, resourceid, resourceRequested, specName);
      return reported != null ? reported : toSimulatedResource(resourceid, resourceRequested);
    }

@@ -97,6 +106,11 @@ public class ResourceRepoService {
    return aCatalogClient.updateResourceById( resourceid, resourceUpdate);
  }

  /** Spec name of a resource, or null when unset — unlike create/update, delete never resolved one. */
  private static String specNameOf(ResourceUpdate r) {
    return r != null && r.getResourceSpecification() != null ? r.getResourceSpecification().getName() : null;
  }

  /** Extracts the resource-id-to-update-back header, or "" when absent. */
  private String resourceIdFromHeaders(Map<String, Object> headers) {
    Object id = headers.get(HDR_RESOURCE_ID);
@@ -122,40 +136,97 @@ public class ResourceRepoService {
   * resource id. OSOM's planning check-deployment wait loop observes the RESERVED&rarr;AVAILABLE
   * progression and converges (the service reaches ACTIVE). Returns null (caller falls back to a local
   * echo) if the queue is unreachable.
   *
   * <p>Each callback also carries the estimate for the phase it represents — creation on the RESERVED
   * post, operation on the AVAILABLE one — which OSOM merges onto the same tentative resource.
   */
  private Resource sendPlanningResourceCallback(Map<String, Object> headers, String resourceid, ResourceUpdate resourceRequested) {
  private Resource sendPlanningResourceCallback(Map<String, Object> headers, String resourceid,
      ResourceUpdate resourceRequested, String specName) {
    // Synchronous: resource is RESERVED (deployment requested / in progress).
    Resource reported = postPlanningResourceCallback(headers, resourceid, resourceRequested, ResourceStatusType.RESERVED);
    Resource reported = postPlanningResourceCallback(headers, resourceid, resourceRequested,
        ResourceStatusType.RESERVED, "creation", ResourceEstimator.creation(specName));

    // Asynchronous, after a simulated delay: resource becomes AVAILABLE. Target the id OSOM
    // acknowledged (fall back to the placeholder we sent) so the second callback updates the same
    // ExpectedResource rather than creating a new one.
    String effectiveId = (reported != null && reported.getUuid() != null) ? reported.getUuid() : resourceid;
    scheduleAvailableProgress(headers, effectiveId, resourceRequested);
    scheduleAvailableProgress(headers, effectiveId, resourceRequested, specName);

    return reported;
  }

  /** Schedules the delayed AVAILABLE planning callback; fire-and-forget, tolerates a downed queue. */
  private void scheduleAvailableProgress(Map<String, Object> headers, String resourceid, ResourceUpdate resourceRequested) {
  private void scheduleAvailableProgress(Map<String, Object> headers, String resourceid,
      ResourceUpdate resourceRequested, String specName) {
    try {
      planningProgressScheduler.schedule(
          () -> postPlanningResourceCallback(headers, resourceid, resourceRequested, ResourceStatusType.AVAILABLE),
          () -> postPlanningResourceCallback(headers, resourceid, resourceRequested,
              ResourceStatusType.AVAILABLE, "operation", operationPhase(headers, specName)),
          Math.max(0, planningSimulatedDeployDelayMs), TimeUnit.MILLISECONDS);
    } catch (Exception e) {
      logger.error("Failed to schedule planning AVAILABLE progress callback: " + e);
    }
  }

  /** Posts a single planning resource callback carrying the given {@link ResourceStatusType}. */
  /**
   * Builds the OPERATION estimate from the window OSOM sent on the request. Null when the window is
   * absent: OSOM owns the default, so inventing one here would silently mask a missing header and
   * report a confident figure for a duration nobody chose.
   */
  private ResourceEstimator.Phase operationPhase(Map<String, Object> headers, String specName) {
    Long window = operationDurationFrom(headers);
    if (window == null) {
      logger.info("No " + HDR_OPERATION_DURATION_SECONDS
          + " on the planning request; reporting no operation estimate for spec " + specName);
      return null;
    }
    return ResourceEstimator.operation(specName, window);
  }

  /** Reads the operation window OSOM supplies on a planning request; null when absent or unparseable. */
  private Long operationDurationFrom(Map<String, Object> headers) {
    Object v = headers.get(HDR_OPERATION_DURATION_SECONDS);
    if (v == null) {
      return null;
    }
    try {
      return Long.valueOf(String.valueOf(v).trim());
    } catch (NumberFormatException e) {
      logger.warn("Ignoring unparseable " + HDR_OPERATION_DURATION_SECONDS + ": " + v);
      return null;
    }
  }

  /** Emits one phase's three estimate headers; no-ops for a null phase or a null component. */
  private void putPhase(Map<String, Object> headers, String phaseName, ResourceEstimator.Phase p) {
    if (p == null) {
      return;
    }
    if (p.durationSeconds() != null) {
      headers.put(HDR_EST_PREFIX + phaseName + ".durationSeconds", String.valueOf(p.durationSeconds()));
    }
    if (p.energyWh() != null) {
      headers.put(HDR_EST_PREFIX + phaseName + ".energy", String.valueOf(p.energyWh()));
    }
    if (p.cost() != null) {
      headers.put(HDR_EST_PREFIX + phaseName + ".cost", String.valueOf(p.cost()));
    }
  }

  /**
   * Posts a single planning resource callback carrying the given {@link ResourceStatusType} and, when
   * supplied, the simulated estimate for one lifecycle phase.
   */
  private Resource postPlanningResourceCallback(Map<String, Object> headers, String resourceid,
      ResourceUpdate resourceRequested, ResourceStatusType status) {
      ResourceUpdate resourceRequested, ResourceStatusType status,
      String phaseName, ResourceEstimator.Phase phase) {
    try {
      Map<String, Object> callbackHeaders = new HashMap<>();
      callbackHeaders.put("resourceName", resourceRequested.getName());
      callbackHeaders.put("resourceVersion", resourceRequested.getResourceVersion());
      callbackHeaders.put("resourceCategory", resourceRequested.getCategory());
      callbackHeaders.put("org.etsi.osl.resourceStatus", status.name());
      putPhase(callbackHeaders, phaseName, phase);
      if (headers.get(HDR_SERVICE_ORDER_ID) != null) {
        callbackHeaders.put(HDR_SERVICE_ORDER_ID, headers.get(HDR_SERVICE_ORDER_ID));
      }
@@ -219,7 +290,8 @@ public class ResourceRepoService {
      // PLANNING.RESOURCE.CREATEORUPDATE callback queue.
      applyResourceLogic(specName, r, resourceid, true);

      Resource reported = postPlanningResourceCallback(headers, resourceid, r, ResourceStatusType.AVAILABLE);
      Resource reported = postPlanningResourceCallback(headers, resourceid, r, ResourceStatusType.AVAILABLE,
          "operation", operationPhase(headers, specName));
      return reported != null ? reported : toSimulatedResource(resourceid, r);
    }
    
@@ -240,7 +312,10 @@ public class ResourceRepoService {
      logger.info("======================================================");

      String resourceid = resourceIdFromHeaders(headers); //the resource to update back
      Resource reported = postPlanningResourceCallback(headers, resourceid, r, ResourceStatusType.UNKNOWN);
      Resource reported = postPlanningResourceCallback(headers, resourceid, r, ResourceStatusType.UNKNOWN,
          "teardown", ResourceEstimator.teardown(specNameOf(r)));
      Resource resource = toSimulatedResource(resourceid, r);
      resource.setResourceStatus(ResourceStatusType.UNKNOWN );
      return reported != null ? reported : toSimulatedResource(resourceid, r);

    }
Loading