
001/*- 002 * #%L 003 * HAPI FHIR Storage api 004 * %% 005 * Copyright (C) 2014 - 2025 Smile CDR, Inc. 006 * %% 007 * Licensed under the Apache License, Version 2.0 (the "License"); 008 * you may not use this file except in compliance with the License. 009 * You may obtain a copy of the License at 010 * 011 * http://www.apache.org/licenses/LICENSE-2.0 012 * 013 * Unless required by applicable law or agreed to in writing, software 014 * distributed under the License is distributed on an "AS IS" BASIS, 015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 016 * See the License for the specific language governing permissions and 017 * limitations under the License. 018 * #L% 019 */ 020package ca.uhn.fhir.jpa.partition; 021 022import ca.uhn.fhir.context.FhirContext; 023import ca.uhn.fhir.context.RuntimeResourceDefinition; 024import ca.uhn.fhir.i18n.Msg; 025import ca.uhn.fhir.interceptor.api.HookParams; 026import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster; 027import ca.uhn.fhir.interceptor.api.Pointcut; 028import ca.uhn.fhir.interceptor.model.ReadPartitionIdRequestDetails; 029import ca.uhn.fhir.interceptor.model.RequestPartitionId; 030import ca.uhn.fhir.jpa.model.config.PartitionSettings; 031import ca.uhn.fhir.jpa.model.util.JpaConstants; 032import ca.uhn.fhir.rest.api.server.RequestDetails; 033import ca.uhn.fhir.rest.api.server.SystemRequestDetails; 034import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; 035import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; 036import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; 037import ca.uhn.fhir.rest.server.util.CompositeInterceptorBroadcaster; 038import ca.uhn.fhir.util.Logs; 039import jakarta.annotation.Nonnull; 040import jakarta.annotation.Nullable; 041import org.apache.commons.lang3.StringUtils; 042import org.hl7.fhir.instance.model.api.IBaseResource; 043import org.slf4j.Logger; 044import org.springframework.beans.factory.annotation.Autowired; 045 046import java.util.ArrayList; 047import java.util.Arrays; 048import java.util.HashSet; 049import java.util.List; 050import java.util.Set; 051import java.util.stream.Collectors; 052 053public abstract class BaseRequestPartitionHelperSvc implements IRequestPartitionHelperSvc { 054 055 public static final Logger ourLog = Logs.getPartitionTroubleshootingLog(); 056 private final HashSet<Object> myNonPartitionableResourceNames; 057 058 @Autowired 059 protected FhirContext myFhirContext; 060 061 @Autowired 062 private IInterceptorBroadcaster myInterceptorBroadcaster; 063 064 PartitionSettings myPartitionSettings; 065 066 protected BaseRequestPartitionHelperSvc() { 067 myNonPartitionableResourceNames = new HashSet<>(); 068 069 // Infrastructure 070 myNonPartitionableResourceNames.add("SearchParameter"); 071 072 // Validation and Conformance 073 myNonPartitionableResourceNames.add("StructureDefinition"); 074 myNonPartitionableResourceNames.add("Questionnaire"); 075 myNonPartitionableResourceNames.add("CapabilityStatement"); 076 myNonPartitionableResourceNames.add("CompartmentDefinition"); 077 myNonPartitionableResourceNames.add("OperationDefinition"); 078 079 myNonPartitionableResourceNames.add("Library"); 080 081 // Terminology 082 myNonPartitionableResourceNames.add("ConceptMap"); 083 myNonPartitionableResourceNames.add("CodeSystem"); 084 myNonPartitionableResourceNames.add("ValueSet"); 085 myNonPartitionableResourceNames.add("NamingSystem"); 086 myNonPartitionableResourceNames.add("StructureMap"); 087 } 088 089 @Autowired 090 public void setPartitionSettings(PartitionSettings thePartitionSettings) { 091 myPartitionSettings = thePartitionSettings; 092 } 093 094 /** 095 * Invoke the {@link Pointcut#STORAGE_PARTITION_IDENTIFY_READ} interceptor pointcut to determine the tenant for a read request. 096 * <p> 097 * If no interceptors are registered with a hook for {@link Pointcut#STORAGE_PARTITION_IDENTIFY_READ}, return 098 * {@link RequestPartitionId#allPartitions()} instead. 099 */ 100 @Nonnull 101 @Override 102 public RequestPartitionId determineReadPartitionForRequest( 103 @Nullable RequestDetails theRequest, @Nonnull ReadPartitionIdRequestDetails theDetails) { 104 if (!myPartitionSettings.isPartitioningEnabled()) { 105 return RequestPartitionId.allPartitions(); 106 } 107 108 // certain use-cases (e.g. batch2 jobs), only have resource type populated in the ReadPartitionIdRequestDetails 109 // TODO MM: see if we can make RequestDetails consistent 110 String resourceType = theDetails.getResourceType(); 111 112 RequestDetails requestDetails = theRequest; 113 // TODO GGG eventually, theRequest will not be allowed to be null here, and we will pass through 114 // SystemRequestDetails instead. 115 if (requestDetails == null) { 116 requestDetails = new SystemRequestDetails(); 117 logSubstitutingDefaultSystemRequestDetails(); 118 } 119 120 boolean nonPartitionableResource = isResourceNonPartitionable(resourceType); 121 122 RequestPartitionId requestPartitionId = null; 123 // Handle system requests 124 if (requestDetails instanceof SystemRequestDetails 125 && systemRequestHasExplicitPartition((SystemRequestDetails) requestDetails) 126 && !nonPartitionableResource) { 127 requestPartitionId = getSystemRequestPartitionId((SystemRequestDetails) requestDetails, false); 128 logSystemRequestDetailsResolution((SystemRequestDetails) requestDetails); 129 130 } else if ((requestDetails instanceof SystemRequestDetails) && nonPartitionableResource) { 131 requestPartitionId = RequestPartitionId.fromPartitionId(myPartitionSettings.getDefaultPartitionId()); 132 logSystemRequestDetailsResolution((SystemRequestDetails) requestDetails); 133 logNonPartitionableType(resourceType); 134 } else { 135 // TODO mb: why is this path different than create? 136 // Here, a non-partitionable resource is still delivered to the pointcuts. 137 IInterceptorBroadcaster compositeBroadcaster = 138 CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, requestDetails); 139 if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_ANY)) { 140 requestPartitionId = callAnyPointcut(compositeBroadcaster, requestDetails); 141 } else if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_READ)) { 142 requestPartitionId = callReadPointcut(compositeBroadcaster, requestDetails, theDetails); 143 } 144 } 145 146 validateRequestPartitionNotNull( 147 requestPartitionId, Pointcut.STORAGE_PARTITION_IDENTIFY_ANY, Pointcut.STORAGE_PARTITION_IDENTIFY_READ); 148 149 RequestPartitionId resultRequestPartitionId = 150 validateAndNormalizePartition(requestPartitionId, requestDetails, resourceType); 151 logTroubleshootingResult("read", resourceType, theRequest, resultRequestPartitionId); 152 153 return resultRequestPartitionId; 154 } 155 156 private static RequestPartitionId callAnyPointcut( 157 IInterceptorBroadcaster compositeBroadcaster, RequestDetails requestDetails) { 158 // Interceptor call: STORAGE_PARTITION_IDENTIFY_ANY 159 HookParams params = new HookParams() 160 .add(RequestDetails.class, requestDetails) 161 .addIfMatchesType(ServletRequestDetails.class, requestDetails); 162 163 return callAndLog(compositeBroadcaster, Pointcut.STORAGE_PARTITION_IDENTIFY_ANY, params); 164 } 165 166 private static RequestPartitionId callCreatePointcut( 167 IInterceptorBroadcaster compositeBroadcaster, 168 RequestDetails requestDetails, 169 @Nonnull IBaseResource theResource) { 170 // Interceptor call: STORAGE_PARTITION_IDENTIFY_CREATE 171 HookParams params = new HookParams() 172 .add(IBaseResource.class, theResource) 173 .add(RequestDetails.class, requestDetails) 174 .addIfMatchesType(ServletRequestDetails.class, requestDetails); 175 176 return callAndLog(compositeBroadcaster, Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE, params); 177 } 178 179 private static RequestPartitionId callAndLog( 180 IInterceptorBroadcaster compositeBroadcaster, Pointcut pointcut, HookParams params) { 181 RequestPartitionId result = 182 (RequestPartitionId) compositeBroadcaster.callHooksAndReturnObject(pointcut, params); 183 184 if (ourLog.isTraceEnabled()) { 185 ourLog.trace( 186 "{}: result={} hooks={}", pointcut, result, compositeBroadcaster.getInvokersForPointcut(pointcut)); 187 } 188 return result; 189 } 190 191 private static RequestPartitionId callReadPointcut( 192 IInterceptorBroadcaster compositeBroadcaster, 193 RequestDetails requestDetails, 194 @Nonnull ReadPartitionIdRequestDetails theDetails) { 195 // Interceptor call: STORAGE_PARTITION_IDENTIFY_READ 196 HookParams params = new HookParams() 197 .add(RequestDetails.class, requestDetails) 198 .addIfMatchesType(ServletRequestDetails.class, requestDetails) 199 .add(ReadPartitionIdRequestDetails.class, theDetails); 200 201 return callAndLog(compositeBroadcaster, Pointcut.STORAGE_PARTITION_IDENTIFY_READ, params); 202 } 203 204 private static void logNonPartitionableType(String theResourceType) { 205 ourLog.trace("Partitioning: resource type {} must be on the DEFAULT partition.", theResourceType); 206 } 207 208 @Override 209 public RequestPartitionId determineGenericPartitionForRequest(RequestDetails theRequestDetails) { 210 RequestPartitionId requestPartitionId = null; 211 212 if (!myPartitionSettings.isPartitioningEnabled()) { 213 return RequestPartitionId.allPartitions(); 214 } 215 216 if (theRequestDetails instanceof SystemRequestDetails 217 && systemRequestHasExplicitPartition((SystemRequestDetails) theRequestDetails)) { 218 requestPartitionId = getSystemRequestPartitionId((SystemRequestDetails) theRequestDetails); 219 logSystemRequestDetailsResolution((SystemRequestDetails) theRequestDetails); 220 } else { 221 IInterceptorBroadcaster compositeBroadcaster = CompositeInterceptorBroadcaster.newCompositeBroadcaster( 222 myInterceptorBroadcaster, theRequestDetails); 223 if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_ANY)) { 224 requestPartitionId = callAnyPointcut(compositeBroadcaster, theRequestDetails); 225 } 226 } 227 228 // TODO MM: at the moment it is ok for this method to return null 229 // check if it can be made consistent and it's implications 230 // validateRequestPartitionNotNull(requestPartitionId, Pointcut.STORAGE_PARTITION_IDENTIFY_ANY); 231 232 if (requestPartitionId != null) { 233 requestPartitionId = validateAndNormalizePartition( 234 requestPartitionId, theRequestDetails, theRequestDetails.getResourceName()); 235 } 236 237 logTroubleshootingResult("generic", theRequestDetails.getResourceName(), theRequestDetails, requestPartitionId); 238 239 return requestPartitionId; 240 } 241 242 /** 243 * For system requests, read partition from tenant ID if present, otherwise set to DEFAULT. If the resource they are attempting to partition 244 * is non-partitionable scream in the logs and set the partition to DEFAULT. 245 */ 246 private RequestPartitionId getSystemRequestPartitionId( 247 SystemRequestDetails theRequest, boolean theNonPartitionableResource) { 248 RequestPartitionId requestPartitionId; 249 requestPartitionId = getSystemRequestPartitionId(theRequest); 250 if (theNonPartitionableResource 251 && !requestPartitionId.isDefaultPartition(myPartitionSettings.getDefaultPartitionId())) { 252 throw new InternalErrorException(Msg.code(1315) 253 + "System call is attempting to write a non-partitionable resource to a partition! This is a bug!"); 254 } 255 return requestPartitionId; 256 } 257 258 /** 259 * Determine the partition for a System Call (defined by the fact that the request is of type SystemRequestDetails) 260 * <p> 261 * 1. If the tenant ID is set to the constant for all partitions, return all partitions 262 * 2. If there is a tenant ID set in the request, use it. 263 * 3. Otherwise, return the Default Partition. 264 * 265 * @param theRequest The {@link SystemRequestDetails} 266 * @return the {@link RequestPartitionId} to be used for this request. 267 */ 268 @Nonnull 269 private RequestPartitionId getSystemRequestPartitionId(@Nonnull SystemRequestDetails theRequest) { 270 if (theRequest.getRequestPartitionId() != null) { 271 return theRequest.getRequestPartitionId(); 272 } 273 if (theRequest.getTenantId() != null) { 274 // TODO: JA2 we should not be inferring the partition name from the tenant name 275 return RequestPartitionId.fromPartitionName(theRequest.getTenantId()); 276 } else { 277 return RequestPartitionId.defaultPartition(); 278 } 279 } 280 281 /** 282 * Invoke the {@link Pointcut#STORAGE_PARTITION_IDENTIFY_CREATE} interceptor pointcut to determine the tenant for a create request. 283 */ 284 @Nonnull 285 @Override 286 public RequestPartitionId determineCreatePartitionForRequest( 287 @Nullable final RequestDetails theRequest, 288 @Nonnull IBaseResource theResource, 289 @Nonnull String theResourceType) { 290 291 if (!myPartitionSettings.isPartitioningEnabled()) { 292 return RequestPartitionId.allPartitions(); 293 } 294 295 RequestDetails requestDetails = theRequest; 296 boolean nonPartitionableResource = isResourceNonPartitionable(theResourceType); 297 298 // TODO GGG eventually, theRequest will not be allowed to be null here, and we will pass through 299 // SystemRequestDetails instead. 300 if (theRequest == null) { 301 requestDetails = new SystemRequestDetails(); 302 logSubstitutingDefaultSystemRequestDetails(); 303 } 304 305 RequestPartitionId requestPartitionId = null; 306 if (theRequest instanceof SystemRequestDetails 307 && systemRequestHasExplicitPartition((SystemRequestDetails) theRequest)) { 308 requestPartitionId = 309 getSystemRequestPartitionId((SystemRequestDetails) theRequest, nonPartitionableResource); 310 311 logSystemRequestDetailsResolution((SystemRequestDetails) theRequest); 312 } else { 313 IInterceptorBroadcaster compositeBroadcaster = 314 CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, requestDetails); 315 if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_ANY)) { 316 requestPartitionId = callAnyPointcut(compositeBroadcaster, requestDetails); 317 } else if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE)) { 318 requestPartitionId = callCreatePointcut(compositeBroadcaster, requestDetails, theResource); 319 } 320 } 321 322 // If the interceptors haven't selected a partition, and its a non-partitionable resource anyhow, send 323 // to DEFAULT 324 if (nonPartitionableResource && requestPartitionId == null) { 325 logNonPartitionableType(theResourceType); 326 requestPartitionId = RequestPartitionId.fromPartitionId(myPartitionSettings.getDefaultPartitionId()); 327 } 328 329 validateRequestPartitionNotNull( 330 requestPartitionId, 331 Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE, 332 Pointcut.STORAGE_PARTITION_IDENTIFY_ANY); 333 validatePartitionForCreate(requestPartitionId, theResourceType); 334 335 RequestPartitionId resultRequestPartitionId = 336 validateAndNormalizePartition(requestPartitionId, requestDetails, theResourceType); 337 338 logTroubleshootingResult("create", theResourceType, theRequest, resultRequestPartitionId); 339 340 return resultRequestPartitionId; 341 } 342 343 private boolean systemRequestHasExplicitPartition(@Nonnull SystemRequestDetails theRequest) { 344 return theRequest.getRequestPartitionId() != null || theRequest.getTenantId() != null; 345 } 346 347 @Nonnull 348 @Override 349 public Set<Integer> toReadPartitions(@Nonnull RequestPartitionId theRequestPartitionId) { 350 return theRequestPartitionId.getPartitionIds().stream() 351 .map(t -> t == null ? myPartitionSettings.getDefaultPartitionId() : t) 352 .collect(Collectors.toSet()); 353 } 354 355 /** 356 * If the partition only has a name but not an ID, this method resolves the ID. 357 * <p> 358 * If the partition has an ID but not a name, the name is resolved. 359 * <p> 360 * If the partition has both, they are validated to ensure that they correspond. 361 */ 362 @Nonnull 363 private RequestPartitionId validateAndNormalizePartition( 364 @Nonnull RequestPartitionId theRequestPartitionId, 365 RequestDetails theRequest, 366 @Nullable String theResourceType) { 367 RequestPartitionId retVal = theRequestPartitionId; 368 369 if (!myPartitionSettings.isUnnamedPartitionMode()) { 370 if (retVal.getPartitionNames() != null) { 371 retVal = validateAndNormalizePartitionNames(retVal); 372 } else if (retVal.hasPartitionIds()) { 373 retVal = validateAndNormalizePartitionIds(retVal); 374 } 375 } 376 377 // Note: It's still possible that the partition only has a date but no name/id 378 379 if (StringUtils.isNotBlank(theResourceType)) { 380 validateHasPartitionPermissions(theRequest, theResourceType, retVal); 381 } 382 383 // Replace null partition ID with non-null default partition ID if one is being used 384 if (myPartitionSettings.getDefaultPartitionId() != null 385 && retVal.hasPartitionIds() 386 && retVal.hasDefaultPartitionId(null)) { 387 List<Integer> partitionIds = new ArrayList<>(retVal.getPartitionIds()); 388 for (int i = 0; i < partitionIds.size(); i++) { 389 if (partitionIds.get(i) == null) { 390 partitionIds.set(i, myPartitionSettings.getDefaultPartitionId()); 391 } 392 } 393 retVal = RequestPartitionId.fromPartitionIds(partitionIds); 394 } 395 396 ourLog.trace("Partition normalization: {} -> {}", theRequestPartitionId, retVal); 397 398 return retVal; 399 } 400 401 @Override 402 public void validateHasPartitionPermissions( 403 @Nonnull RequestDetails theRequest, String theResourceType, RequestPartitionId theRequestPartitionId) { 404 IInterceptorBroadcaster compositeBroadcaster = 405 CompositeInterceptorBroadcaster.newCompositeBroadcaster(myInterceptorBroadcaster, theRequest); 406 if (compositeBroadcaster.hasHooks(Pointcut.STORAGE_PARTITION_SELECTED)) { 407 RuntimeResourceDefinition runtimeResourceDefinition = null; 408 if (theResourceType != null) { 409 runtimeResourceDefinition = myFhirContext.getResourceDefinition(theResourceType); 410 } 411 HookParams params = new HookParams() 412 .add(RequestPartitionId.class, theRequestPartitionId) 413 .add(RequestDetails.class, theRequest) 414 .addIfMatchesType(ServletRequestDetails.class, theRequest) 415 .add(RuntimeResourceDefinition.class, runtimeResourceDefinition); 416 compositeBroadcaster.callHooks(Pointcut.STORAGE_PARTITION_SELECTED, params); 417 } 418 } 419 420 @Override 421 public boolean isResourcePartitionable(String theResourceType) { 422 return theResourceType != null && !myNonPartitionableResourceNames.contains(theResourceType); 423 } 424 425 @Override 426 @Nullable 427 public Integer getDefaultPartitionId() { 428 return myPartitionSettings.getDefaultPartitionId(); 429 } 430 431 private boolean isResourceNonPartitionable(String theResourceType) { 432 return theResourceType != null && !isResourcePartitionable(theResourceType); 433 } 434 435 private void validatePartitionForCreate(RequestPartitionId theRequestPartitionId, String theResourceName) { 436 if (theRequestPartitionId.hasPartitionIds()) { 437 validateSinglePartitionIdOrName(theRequestPartitionId.getPartitionIds()); 438 } 439 validateSinglePartitionIdOrName(theRequestPartitionId.getPartitionNames()); 440 441 // Make sure we're not using one of the conformance resources in a non-default partition 442 if (isDefaultPartition(theRequestPartitionId) || theRequestPartitionId.isAllPartitions()) { 443 return; 444 } 445 446 // TODO MM: check if we need to validate using the configured value PartitionSettings.defaultPartition 447 // however that is only used for read and not for create at the moment 448 if ((theRequestPartitionId.hasPartitionIds() 449 && !theRequestPartitionId.getPartitionIds().contains(null)) 450 || (theRequestPartitionId.hasPartitionNames() 451 && !theRequestPartitionId.getPartitionNames().contains(JpaConstants.DEFAULT_PARTITION_NAME))) { 452 453 if (isResourceNonPartitionable(theResourceName)) { 454 String msg = myFhirContext 455 .getLocalizer() 456 .getMessageSanitized( 457 BaseRequestPartitionHelperSvc.class, 458 "nonDefaultPartitionSelectedForNonPartitionable", 459 theResourceName); 460 throw new UnprocessableEntityException(Msg.code(1318) + msg); 461 } 462 } 463 } 464 465 private static void validateRequestPartitionNotNull( 466 RequestPartitionId theRequestPartitionId, Pointcut... thePointcuts) { 467 if (theRequestPartitionId == null) { 468 throw new InternalErrorException( 469 Msg.code(1319) + "No interceptor provided a value for pointcuts: " + Arrays.toString(thePointcuts)); 470 } 471 } 472 473 private static void validateSinglePartitionIdOrName(@Nullable List<?> thePartitionIds) { 474 if (thePartitionIds != null && thePartitionIds.size() != 1) { 475 throw new InternalErrorException( 476 Msg.code(1320) + "RequestPartitionId must contain a single partition for create operations, found: " 477 + thePartitionIds); 478 } 479 } 480 481 private static void logTroubleshootingResult( 482 String theAction, 483 String theResourceType, 484 @Nullable RequestDetails theRequest, 485 RequestPartitionId theResult) { 486 String tenantId = theRequest != null ? theRequest.getTenantId() : null; 487 ourLog.debug( 488 "Partitioning: action={} resource type={} with request tenant ID={} routed to RequestPartitionId={}", 489 theAction, 490 theResourceType, 491 tenantId, 492 theResult); 493 } 494 495 private void logSystemRequestDetailsResolution(SystemRequestDetails theRequest) { 496 ourLog.trace( 497 "Partitioning: request is a SystemRequestDetails, with RequestPartitionId={}.", 498 theRequest.getRequestPartitionId()); 499 } 500 501 private static void logSubstitutingDefaultSystemRequestDetails() { 502 ourLog.trace("No RequestDetails present. Using default SystemRequestDetails."); 503 } 504}