diff --git a/includes/callback.inc b/includes/callback.inc index 1efdf00..aa466af 100644 --- a/includes/callback.inc +++ b/includes/callback.inc @@ -181,6 +181,12 @@ function js_callback_bootstrap(array &$info) { module_invoke($module, 'init'); } } + + // At this point in the execution flow it is safe to allow our cache handler + // to perform a full bootstrap in case of cache misses. + if ($info['cache']) { + JsProxyCache::setFullBootstrapAllowed(TRUE); + } } } diff --git a/js.api.php b/js.api.php index a4115bf..d150b57 100644 --- a/js.api.php +++ b/js.api.php @@ -53,11 +53,23 @@ * instance, loading an entity when entity cache is cold may result in some * data not being loaded and entity cache being corrupt; saving that entity * in subsequent requests may even lead to data loss, if the cache entry was - * not refreshed meanwhile. A possible solution is raising the bootstrap - * level to full, although this defeats the purpose of using this module. - * An alternative solution is monitoring the code paths triggered by the - * callback via the "xhprof" integration and make sure all required - * dependencies are actually loaded. + * not refreshed meanwhile. + * Note: by default JS Callback intercepts requests via core's Cache API. + * When a cache miss is detected, it will automatically perform a full + * bootstrap in an attempt to ensure all modules affecting the data to be + * cached are loaded. See "cache" property for more info. + * This does not, however, solve the general issue where a complex callback + * performs a storage write via an API that allows data to be altered via + * hook implementations. In cases like this all dependencies need to be + * explicitly loaded. + * A temporary solution is to raise the bootstrap level to full. However, + * this defeats the entire purpose of using this module. + * A more permanent solution is to monitor the execution path of a callback + * via the "xhprof" integration and ensure all required dependencies are + * added to the callback info. + * - cache: (optional) Flag indicating whether a full bootstrap should be + * performed when detecting a cache miss. Defaults to TRUE. See "bootstrap" + * property for more info. * - includes: (optional) Load additional files from the /includes directory, * without the extension. * - dependencies: (optional) Load additional modules for this callback. diff --git a/js.module b/js.module index 739489b..8f463bd 100644 --- a/js.module +++ b/js.module @@ -88,11 +88,28 @@ function js_preprocess_html() { /** * Implements hook_custom_theme(). - * - * Used to set the theme to be used for JS requests. */ function js_custom_theme() { global $_js; + + // During a full bootstrap, hook_custom_theme() is invoked just before + // hook_init(). Here we can make sure that hook_init() implementations + // provided by callback dependencies are not invoked twice, in case a full + // bootstrap is performed after invoking them in js_callback_bootstrap(). + if (!empty($_js['module']) && !empty($_js['callback'])) { + $info = js_get_callback($_js['module'], $_js['callback']); + if (!$info['skip init'] && $info['dependencies']) { + $implementations = &drupal_static('module_implements'); + // After a global cache clear, hook implementation info is not available + // yet, so we explicitly rebuild it. + if (!isset($implementations['init'])) { + module_implements('init'); + } + $implementations['init'] = array_diff_key($implementations['init'], array_flip($info['dependencies'])); + } + } + + // Set the theme to be used for JS requests. if (!empty($_js['theme'])) { return $_js['theme']; } @@ -488,10 +505,8 @@ function js_execute_request() { register_shutdown_function('_js_fatal_error_handler'); } - // Memcache requires an additional bootstrap phase to access variables. - if (!empty($conf['cache_default_class']) && $conf['cache_default_class'] === 'MemCacheDrupal') { - js_bootstrap(DRUPAL_BOOTSTRAP_VARIABLES); - } + // Initialize the cache system and our custom handler. + _js_cache_initialize(); // Immediately clone the request method so it cannot be altered any further. static $method; @@ -605,6 +620,42 @@ function js_execute_request() { } /** + * Initializes the cache system and our custom handler. + */ +function _js_cache_initialize() { + global $conf; + + // Skip autoloading, we do not need its overhead. Additionally it may trigger + // cache initialization. + module_load_include('php', 'js', 'src/JsProxyCache'); + + // Collect all the explicitly configured cache bins. + $default_key = JsProxyCache::DEFAULT_BIN_KEY; + $cache_bin_keys = array_values(array_filter(array_keys($conf), function ($key) { + return strpos($key, 'cache_class_') === 0; + })); + $cache_bin_keys[] = $default_key; + + // Save the current configuration and override it to make sure an instance of + // our custom wrapper is instantiated for each configured bin. + $cache_conf = array(); + $default_class = isset($conf[$default_key]) ? $conf[$default_key] : 'DrupalDatabaseCache'; + foreach ($cache_bin_keys as $bin_key) { + $cache_conf[$bin_key] = isset($conf[$bin_key]) ? $conf[$bin_key] : $default_class; + $conf[$bin_key] = 'JsProxyCache'; + } + + // Finally ensure our custom wrappers know which actual cache backend they are + // supposed to use. + JsProxyCache::setConf($cache_conf); + + // Memcache requires an additional bootstrap phase to access variables. + if (!empty($cache_conf[$default_key]) && $cache_conf[$default_key] === 'MemCacheDrupal') { + js_bootstrap(DRUPAL_BOOTSTRAP_VARIABLES); + } +} + +/** * Sends content to the browser via the delivery callback. * * @param mixed $result @@ -636,6 +687,12 @@ function js_deliver($result, array $info = NULL) { module_load_include('inc', 'js', 'includes/json'); } + // Since most callbacks act at a bootstrap level lower than full, that is + // without loading all modules, we need to make sure hook implementation cache + // is never written while processing a JS request. + $implementations = &drupal_static('module_implements'); + unset($implementations['#write_cache']); + // Deliver the results. The delivery callback is responsible for setting the // appropriate headers, handling the result returned from the callback and // exiting the script properly. @@ -784,6 +841,7 @@ function js_get_callback($module = NULL, $callback = NULL, $reset = FALSE) { 'access arguments' => array(), 'access callback' => FALSE, 'bootstrap' => DRUPAL_BOOTSTRAP_DATABASE, + 'cache' => TRUE, // Provide a standard function name to use if none is provided. 'callback function' => $_module . '_js_callback_' . $_callback, 'callback arguments' => array(), diff --git a/src/JsProxyCache.php b/src/JsProxyCache.php new file mode 100644 index 0000000..a8ccbeb --- /dev/null +++ b/src/JsProxyCache.php @@ -0,0 +1,129 @@ +backend = new $class($bin); + } + else { + $message = 'The cache backend configuration for the JS proxy cache handler is invalid: @conf'; + throw new LogicException(format_string($message, array('@conf' => print_r(static::$conf, TRUE)))); + } + } + + /** + * {@inheritdoc} + */ + public function get($cid) { + $cache = $this->backend->get($cid); + if (!$cache) { + $this->doFullBootstrap(); + } + return $cache; + } + + /** + * {@inheritdoc} + */ + public function getMultiple(&$cids) { + $cache = $this->backend->getMultiple($cids); + if ($cids) { + $this->doFullBootstrap(); + } + return $cache; + } + + /** + * {@inheritdoc} + */ + public function set($cid, $data, $expire = CACHE_PERMANENT) { + $this->backend->set($cid, $data, $expire); + } + + /** + * {@inheritdoc} + */ + public function clear($cid = NULL, $wildcard = FALSE) { + $this->backend->clear($cid, $wildcard); + } + + /** + * {@inheritdoc} + */ + public function isEmpty() { + return $this->backend->isEmpty(); + } + + /** + * Fully bootstraps Drupal. + */ + protected function doFullBootstrap() { + if (static::$fullBootstrapAllowed) { + static::setFullBootstrapAllowed(FALSE); + if (drupal_get_bootstrap_phase() < DRUPAL_BOOTSTRAP_FULL) { + js_bootstrap(DRUPAL_BOOTSTRAP_FULL); + } + } + } + +}