diff --git a/.git-hooks-matomo/pre-push b/.git-hooks-matomo/pre-push new file mode 100755 index 0000000..7d06e11 --- /dev/null +++ b/.git-hooks-matomo/pre-push @@ -0,0 +1,346 @@ +#!/bin/bash + +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# + + + +### Check we're running in the context of a plugin and get helpful dir variables ### + +REPO_DIR="$(git rev-parse --show-toplevel)" +echo "Running pre-push hook in repo: $REPO_DIR" + +if [[ "$REPO_DIR" =~ /plugins/(.*) ]]; then + PLUGIN_PATH="plugins/${BASH_REMATCH[1]}/" +else + echo "Not inside a Matomo checkout's plugins/ directory, skipping PHPStan checks" + exit 0 +fi +MATOMO_DIR=$(echo "$REPO_DIR" | sed -E 's|/plugins/.*$||') + + + +### Figure out how to run PHPStan - ddev or not. ### + +COMMAND=() +# Use local PHP if setup +if command -v php >/dev/null 2>&1 && [ -f "${MATOMO_DIR}/vendor/bin/phpstan" ]; then + COMMAND=("${MATOMO_DIR}/vendor/bin/phpstan") + PLUGIN_PATH='' +elif command -v ddev >/dev/null 2>&1; then + # Fall back to ddev when there is no local PHPStan. Local takes priority: it is faster, + # and it is what the elif above actually encodes. + if [ -d "$MATOMO_DIR/.ddev" ]; then + cd "$MATOMO_DIR" || exit 1 + # `ddev status` exits 0 for a stopped project, so its exit code says nothing about whether + # the containers are up. `ddev describe -j` reports the real state. + if [[ "$(ddev describe -j 2>/dev/null | sed -n 's/.*"status":"\([a-z]*\)".*/\1/p' | head -1)" == "running" ]]; then + COMMAND=(ddev exec phpstan) + else + DDEV_STOPPED=1 + fi + fi +fi +# If no command, exit +if [[ ${#COMMAND[@]} -eq 0 ]]; then + if [[ "${DDEV_STOPPED:-0}" -eq 1 ]]; then + # The tooling exists and simply is not started. Blocking the push here teaches people to + # reach for --no-verify, which is worse than skipping one check. + echo "ddev is not running, so PHPStan was skipped. Run 'ddev start' to check before pushing." + exit 0 + fi + echo "No way to run phpstan found." + exit 1 +fi + + + +# Basic setup +cd "$REPO_DIR" || exit 1 +STATUS=0 +ZERO_OID='0000000000000000000000000000000000000000' +PHPSTAN_CREATED_CONFIG=phpstan/phpstan.created.neon +PHPSTAN_MODIFIED_CONFIG=phpstan/phpstan.modified.neon + + + +### Work out what a pushed commit should be compared against. ### + +# The nearest origin/.x-dev, measured in commits between the merge base and the push. +# origin/HEAD is wrong twice over: git records it at clone time and never refreshes it, so a clone +# made while the default was 5.x-dev still names 5.x-dev long after the plugin moved to 6.x-dev; +# and the default branch is not the base of a backport branch in any case. Both mistakes widen the +# diff to files the push never touched. Distance needs no network and no naming convention. +# +# Assigns MAIN_BRANCH and BASE_BRANCH_TIED instead of echoing: reading stdout needs a command +# substitution, and the subshell would throw the tie flag away. Tied means two majors are equally +# near -- the branch predates their divergence, so the merge base, and with it the file list, is +# the same either way and only the label is a guess. +# +# $1 -- the pushed commit +resolve_base_branch() { + local commit="$1" + local ref merge_base distance best_branch='' best_distance='' + + BASE_BRANCH_TIED=0 + for ref in $(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/*.x-dev'); do + merge_base=$(git merge-base "$commit" "$ref" 2>/dev/null) || continue + distance=$(git rev-list --count "${merge_base}..${commit}") + if [[ -z "$best_distance" || "$distance" -lt "$best_distance" ]]; then + best_distance=$distance + best_branch=${ref#origin/} + # A strictly closer candidate settles it, including over an earlier tie between two + # branches that both just lost. + BASE_BRANCH_TIED=0 + elif [[ "$distance" -eq "$best_distance" ]]; then + # for-each-ref sorts ascending, so taking the later ref means the highest major wins a tie. + best_branch=${ref#origin/} + BASE_BRANCH_TIED=1 + fi + done + + if [[ -n "$best_branch" ]]; then + MAIN_BRANCH=$best_branch + return 0 + fi + + # No .x-dev refs at all -- a single-branch clone, or a fork. Fall back to the remote's + # default branch, then to a fixed name for a clone that can reach neither. Neither says anything + # about the target major, so treat it as tied. + local fallback + fallback=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||') + if [[ -z "$fallback" ]]; then + fallback=$(git remote show origin 2>/dev/null | sed -n 's/.*HEAD branch: //p') + fi + BASE_BRANCH_TIED=1 + MAIN_BRANCH=${fallback:-5.x-dev} +} + +# PHPStan analyses the plugin against whichever Matomo checkout happens to contain it, which is not +# necessarily the major the push is based on. A 6.x-dev branch sitting in a Matomo 5 checkout is +# analysed against Matomo 5, and the findings look entirely real -- correct files, correct line +# numbers -- for signatures that simply differ between the majors. Warn rather than fail: the +# mismatch is sometimes deliberate, and a hard failure on a guess is what teaches --no-verify. +# +# $1 -- the base branch resolved for the push +WARNED_BASE_BRANCHES='' +warn_on_core_major_mismatch() { + local base_branch="$1" + local core_version_file="$MATOMO_DIR/core/Version.php" + local core_major branch_major + + [ -f "$core_version_file" ] || return 0 + # A tied resolution did not establish a target major, so there is nothing to compare against. + [ "${BASE_BRANCH_TIED:-0}" -eq 0 ] || return 0 + # Once per base branch, not once per pushed ref. + case " $WARNED_BASE_BRANCHES " in *" $base_branch "*) return 0 ;; esac + WARNED_BASE_BRANCHES="$WARNED_BASE_BRANCHES $base_branch" + + core_major=$(sed -n "s/.*const VERSION = '\([0-9]\{1,\}\)\..*/\1/p" "$core_version_file" | head -1) + # Only `.x-dev` says anything about the target major; any other branch name is left alone. + branch_major=$(printf '%s' "$base_branch" | sed -n 's/^\([0-9]\{1,\}\)\.x-dev$/\1/p') + [ -n "$core_major" ] && [ -n "$branch_major" ] && [ "$core_major" != "$branch_major" ] || return 0 + + echo + echo "WARNING: analysing against Matomo ${core_major}.x in $MATOMO_DIR, but this push is based" + echo " on $base_branch. Findings below may not match CI, which analyses against Matomo" + echo " ${branch_major}.x. Check a finding against a Matomo ${branch_major}.x checkout" + echo " before acting on it." + echo +} + + + +### Run PHPStan on the files a pushed commit adds or changes. ### + +# $1 -- the pushed commit +# $2 -- git diff filter (A for created files, CMR for modified files; R matters because a +# renamed-and-modified file has status R and would otherwise skip the check) +# $3 -- the phpstan config to use +# $4 -- log label for the file kind +check_pushed_commit() { + local commit="$1" filter="$2" config="$3" label="$4" + + if [[ ! -f "$config" ]]; then + return 0 + fi + + # Use the merge base with the remote base branch: the local branch can be stale + # or missing, which silently widens the diff to files the push doesn't touch. + local diff_base + diff_base=$(git merge-base "$commit" "origin/${MAIN_BRANCH}" 2>/dev/null) + if [[ -z "$diff_base" ]]; then + echo "Could not resolve the merge base between ${commit} and origin/${MAIN_BRANCH}." + echo "Run 'git fetch origin ${MAIN_BRANCH}' and push again." + return 1 + fi + + # Read NUL-delimited so a path containing a space stays one argument. Quoting the paths and + # piping through xargs does not: xargs strips the quotes it was given, then splits on the space. + local changed_files=() + local file + while IFS= read -r -d '' file; do + [[ "$file" == *.php ]] && changed_files+=("${PLUGIN_PATH}${file}") + done < <(git diff --name-only -z "$diff_base" "$commit" --diff-filter="$filter") + + if [[ ${#changed_files[@]} -eq 0 ]]; then + echo "No ${label} PHP files" + return 0 + fi + + echo "Running PHPstan on ${label} files" + + local out_file err_file status + out_file=$(mktemp) || { echo "Could not create a temporary file to capture the analysis" >&2; return 1; } + err_file=$(mktemp) || { rm -f "$out_file"; echo "Could not create a temporary file to capture the analysis" >&2; return 1; } + + # The exemption below matches a line of stderr, so the analyser must not be allowed to reshape it. + # All three of these were verified against 2.2.9 to break the match and block the push this exists + # to let through: + # --no-ansi PHPStan decorates its output whenever MSYSTEM and TERM=xterm are set (Git Bash + # sets both), even into a file, wrapping the line in colour codes. + # COLUMNS=120 Symfony wraps its error block to the terminal width, and an exported COLUMNS + # below ~36 splits the line in two. Only the local-PHP path inherits this, which + # is also the only path that can inherit a small COLUMNS in the first place. + # --no-progress both streams are captured, so the bar can never render live; without this the + # run ends by dumping a dead progress bar into the output. + COLUMNS=120 "${COMMAND[@]}" analyse --no-ansi --no-progress -c "${PLUGIN_PATH}${config}" \ + "${changed_files[@]}" > "$out_file" 2> "$err_file" + status=$? + + # PHPStan reports "nothing to analyse" on stderr and leaves stdout empty, while a run that + # analysed anything writes its result table to stdout. That pair is the only signal it offers: + # the exit code is 1 either way, and --error-format=json still emits this one as plain text + # (checked on 2.2.9). Matching stderr alone would accept a real failure whose own message + # happened to contain the phrase, which a custom rule is free to produce. + # + # Any other sign of failure on stderr withdraws the exemption too. That test is keyed to the + # shapes below rather than to stderr being otherwise empty, because Xdebug notices and PHP's own + # deprecation output land on stderr in ordinary dev environments -- treating those as failures + # would re-block precisely the pushes this exemption exists to let through. + # + # [ERROR] is the block PHPStan actually emits. [FATAL] and PHP's own fatals have not been seen + # alongside the no-files line, but a process that died is never a clean "nothing to analyse", and + # a fatal -- unlike a deprecation -- is never benign, so matching them cannot cost a false block. + local no_files_re='^[[:space:]]*(\[ERROR\][[:space:]]+)?No files found to analyse\.?[[:space:]]*$' + local failure_re='^[[:space:]]*(\[(ERROR|FATAL)\]|(PHP )?(Fatal|Parse) error:)' + local other_diagnostics + other_diagnostics=$(grep -E "$failure_re" "$err_file" | grep -vE "$no_files_re") + + if [[ "$status" -ne 0 ]] && [[ ! -s "$out_file" ]] \ + && grep -qE "$no_files_re" "$err_file" \ + && [[ -z "$other_diagnostics" ]] + then + # Reporting an [ERROR] on a push being allowed through is how a hook teaches people to stop + # reading its output, so the one line the message below restates in plain English is dropped -- + # along with the blank lines Symfony pads its block with, which would otherwise be all that + # survives on a quiet run. + # ddev wraps a non-zero exit in its own coloured "Failed to execute command ...: exit status 1", + # which --no-ansi cannot reach because it is ddev's line rather than PHPStan's. On an exempt run + # that is the failure being deliberately overridden. + # + # The escape is a shell literal rather than \x1b inside the sed script, because BSD and busybox + # sed leave \x1b unexpanded and Linux CI cannot catch that regression. LC_ALL=C keeps the + # substitution byte-oriented, so stderr carrying a non-UTF-8 path byte cannot abort it. + local esc=$'\033' + LC_ALL=C sed "s/${esc}\\[[0-9;]*m//g" "$err_file" \ + | grep -vE "$no_files_re" \ + | grep -vE '^Failed to execute command .*: exit status [0-9]+$' \ + | grep -v '^[[:space:]]*$' >&2 + # Name the files: an excludePaths that accidentally matches everything otherwise retires the + # hook as silently as the unset core.hooksPath the sibling audit reports. + echo "Every ${label} file is excluded by ${config}, so there is nothing to analyse: ${changed_files[*]}" + rm -f "$out_file" "$err_file" + return 0 + fi + + cat "$err_file" >&2 + cat "$out_file" + rm -f "$out_file" "$err_file" + return "$status" +} + +# Check the commits actually being pushed, as supplied on stdin: HEAD is wrong +# when pushing another local branch or several refs at once. The inner commands +# read /dev/null so they cannot consume the remaining stdin lines. +# shellcheck disable=SC2034 # remote_ref/remote_oid consume git's 4-field pre-push line +while read -r local_ref local_oid remote_ref remote_oid; do + if [[ "$local_oid" == "$ZERO_OID" ]]; then + continue # deleting the remote ref, nothing is pushed + fi + # Resolved per ref: one push can carry branches based on different majors. + resolve_base_branch "$local_oid" + warn_on_core_major_mismatch "$MAIN_BRANCH" + echo "Checking ${local_ref} (${local_oid}) against origin/${MAIN_BRANCH}" + check_pushed_commit "$local_oid" A "$PHPSTAN_CREATED_CONFIG" "created" < /dev/null || STATUS=1 + # CMR, not CM: a renamed-and-modified PHP file has status R and would otherwise skip the check. + check_pushed_commit "$local_oid" CMR "$PHPSTAN_MODIFIED_CONFIG" "modified" < /dev/null || STATUS=1 +done + +# Don't bother running the full check, as we check changes files already, and +# can assume that the unchanged files don't need rechecking. +# +# Github will check this anyway. +# +# PHPSTAN_BASE_CONFIG=phpstan.neon +# if [[ -f "$PHPSTAN_BASE_CONFIG" ]]; then +# echo "Running PHPstan at a base level on all plugin files" +# $COMMAND analyse -c ${PLUGIN_PATH}/${PHPSTAN_BASE_CONFIG} || STATUS=1 +# fi + +# A plugin whose core.hooksPath is unset has this file and no way to reach it, and nothing runs to +# say so -- which is exactly why four plugins went a year without the check ever firing. A hook that +# does run can see its siblings, so the working ones report the silent ones. +# +# Only plugins that ship the file are considered: a plugin without one has no hook to activate, and +# pointing core.hooksPath at a directory that does not exist would be worse than leaving it alone -- +# git then runs no hook at all, including anything the repository keeps in .git/hooks, and says +# nothing about it. +# +# Advisory, and at most once a day. Someone else's configuration is not grounds to fail a push. +audit_sibling_plugins() { + local plugins_dir="${MATOMO_DIR}/plugins" + local marker="${MATOMO_DIR}/tmp/.matomo-hook-audit" + local dir inactive=() + + [ -d "$plugins_dir" ] || return 0 + + # `find -mmin` rather than `stat`, whose format flags differ between GNU and BSD. + if [ -f "$marker" ] && [ -z "$(find "$marker" -mmin +1440 2>/dev/null)" ]; then + return 0 + fi + if mkdir -p "${MATOMO_DIR}/tmp" 2>/dev/null; then + : > "$marker" 2>/dev/null || true + fi + + for dir in "$plugins_dir"/*/; do + [ -f "${dir}.git-hooks-matomo/pre-push" ] || continue + git -C "$dir" rev-parse --git-dir >/dev/null 2>&1 || continue + [ -n "$(git -C "$dir" config --get core.hooksPath 2>/dev/null)" ] && continue + inactive+=("$(basename "$dir")") + done + + [ ${#inactive[@]} -eq 0 ] && return 0 + + echo + echo "NOTE: ${#inactive[@]} plugin(s) ship a pre-push hook that never runs, because" + echo " core.hooksPath is not set in them: ${inactive[*]}" + echo " Activate with add-git-hooks-to-plugins.sh from matomo-developer-tools." + echo +} + +# Only on a push that is going through: a rejected push's output should stay about the rejection. +if [[ $STATUS -eq 0 ]]; then + audit_sibling_plugins +fi + +exit $STATUS diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..92ce570 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: Plugins CI + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + push: + branches: + - '**.x-dev' + workflow_dispatch: + +permissions: + actions: read + contents: read + pull-requests: read + +jobs: + ci: + uses: matomo-org/plugin-ci-workflows/.github/workflows/plugin-ci.yml@main + with: + plugin-name: QueuedTracking + verify-hook: true diff --git a/.github/workflows/matomo-ai-checklist.yml b/.github/workflows/matomo-ai-checklist.yml deleted file mode 100644 index cc2c022..0000000 --- a/.github/workflows/matomo-ai-checklist.yml +++ /dev/null @@ -1,32 +0,0 @@ -# Action for running tests - -name: AI Checklist - -on: - pull_request: - types: [opened, synchronize, reopened, edited] - -permissions: - actions: read - checks: none - contents: none - deployments: none - issues: none - packages: none - pull-requests: read - repository-projects: none - security-events: none - statuses: none - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - AiChecklist: - runs-on: ubuntu-24.04 - strategy: - fail-fast: false - steps: - - name: Run tests - uses: matomo-org/github-action-checklist-gate@main diff --git a/.github/workflows/phpcs.yml b/.github/workflows/phpcs.yml deleted file mode 100644 index 5331d9d..0000000 --- a/.github/workflows/phpcs.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: PHPCS check - -on: pull_request - -permissions: - actions: read - checks: read - contents: read - deployments: none - issues: read - packages: none - pull-requests: read - repository-projects: none - security-events: none - statuses: read - -jobs: - phpcs: - name: PHPCS - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v4 - with: - lfs: false - persist-credentials: false - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '7.4' - tools: cs2pr - - name: Install dependencies - run: - composer init --name=matomo/queuedtracking --quiet; - composer --no-plugins config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true -n; - composer config repositories.matomo-coding-standards vcs https://github.com/matomo-org/matomo-coding-standards -n; - composer require matomo-org/matomo-coding-standards:dev-master; - composer install --dev --prefer-dist --no-progress --no-suggest - - name: Check PHP code styles - id: phpcs - run: ./vendor/bin/phpcs --report-full --standard=phpcs.xml --report-checkstyle=./phpcs-report.xml - - name: Show PHPCS results in PR - if: ${{ always() && steps.phpcs.outcome == 'failure' }} - run: cs2pr ./phpcs-report.xml --prepend-filename diff --git a/Commands/Monitor.php b/Commands/Monitor.php index b9298d7..c894930 100644 --- a/Commands/Monitor.php +++ b/Commands/Monitor.php @@ -29,9 +29,6 @@ protected function configure() $this->addRequiredValueOption('jumptopage', 'p', 'Jump to page (p).', 1); } - /** - * @return int - */ protected function doExecute(): int { $output = $this->getOutput(); @@ -112,12 +109,15 @@ protected function doExecute(): int } $numInQueue = 0; + // $qPerPAge is never 0 by the time we get here, so the loop below always runs, + // but PHPStan cannot see that and reads the $idx used underneath as undefined + $idx = 0; for ($idxPage = 0; $idxPage < $qPerPAge; $idxPage++) { - $idx = ($qCurrentPage - 1) * $qPerPAge + $idxPage; + $idx = (int) (($qCurrentPage - 1) * $qPerPAge + $idxPage); if (isset($queues[$idx])) { $q = $queues[$idx]->getNumberOfRequestSetsInQueue(); $numInQueue += (int)$q; - $output->writeln(str_pad($idx, 10, " ", STR_PAD_LEFT) . " | " . str_pad(number_format($q), 16, " ", STR_PAD_LEFT)); + $output->writeln(str_pad((string) $idx, 10, " ", STR_PAD_LEFT) . " | " . str_pad(number_format($q), 16, " ", STR_PAD_LEFT)); } else { $output->writeln(str_pad("", 10) . " | " . str_pad("", 16)); } @@ -223,7 +223,7 @@ private function getIterationsFromArg() /** * Loads the `rowperpage` argument from the commands arguments. * - * @return int|null + * @return int */ private function getPerPageFromArg() { @@ -242,7 +242,7 @@ private function getPerPageFromArg() /** * Loads the `jumptopage` argument from the commands arguments. * - * @return int|null + * @return int */ private function getJumpToPageFromArg() { diff --git a/Commands/PrintQueuedRequests.php b/Commands/PrintQueuedRequests.php index 9628360..e060012 100644 --- a/Commands/PrintQueuedRequests.php +++ b/Commands/PrintQueuedRequests.php @@ -23,9 +23,6 @@ protected function configure() $this->addRequiredValueOption('queue-id', null, 'If set, will print only requests of that queue'); } - /** - * @return int - */ protected function doExecute(): int { $input = $this->getInput(); @@ -51,7 +48,7 @@ protected function doExecute(): int $output->writeln(sprintf('Showing requests of queue %s. Use --queue-id=%s to print only information for this queue.', $thisQueueId, $thisQueueId)); $requests = $queue->getRequestSetsToProcess(); - $output->writeln(var_export($requests, 1)); + $output->writeln(var_export($requests, true)); $output->writeln(sprintf('These were the requests of queue %s. Use --queue-id=%s to print only information for this queue.', $thisQueueId, $thisQueueId)); } diff --git a/Commands/Test.php b/Commands/Test.php index 038f0a0..c9861f4 100644 --- a/Commands/Test.php +++ b/Commands/Test.php @@ -45,7 +45,6 @@ protected function configure() * execute the task by calling a method of another class and output any useful information. * * Execute the command like: ./console queuedtracking:test --name="The Piwik Team" - * @return int */ protected function doExecute(): int @@ -104,7 +103,7 @@ protected function doExecute(): int } $output->writeln('Backend version: ' . $backend->getServerVersion()); - $output->writeln('Memory: ' . var_export($backend->getMemoryStats(), 1)); + $output->writeln('Memory: ' . var_export($backend->getMemoryStats(), true)); $redis = $backend->getConnection(); if ($isUsingRedis && !$shouldSkipCheckingMemoryConfigValues) { @@ -204,7 +203,7 @@ protected function doExecute(): int if ($values == array('value3')) { $output->writeln('List feature seems to work fine'); } else { - $output->writeln('List feature seems to work only partially: ' . var_export($values, 1)); + $output->writeln('List feature seems to work only partially: ' . var_export($values, true)); } } else { $output->writeln('List feature seems to not work fine: ' . $redis->getLastError() . ''); @@ -217,7 +216,7 @@ protected function doExecute(): int } /** - * @param \Redis $redis + * @param \Redis|\RedisCluster|\Credis_Client $redis * @param $configName * @return string */ diff --git a/Queue.php b/Queue.php index 8376585..0fbe3fb 100644 --- a/Queue.php +++ b/Queue.php @@ -12,17 +12,16 @@ use Piwik\Plugins\QueuedTracking\Queue\Backend; use Piwik\Tracker\RequestSet; -use Piwik\Plugins\QueuedTracking\Queue\Backend\Redis; class Queue { public const PREFIX = 'trackingQueueV1'; public const JSON_STRING_PARAMS = [ - 'uadata' + 'uadata', ]; /** - * @var Redis + * @var Backend */ private $backend; @@ -119,7 +118,7 @@ public function markRequestSetsAsProcessed() * The request array is passed by reference so that any changes are made to the original array. If any params are * found, this simply encodes them into a JSON string again. * - * @param array $requestArray + * @param mixed $requestArray * @return void */ public function ensureJsonVarsAreStrings(&$requestArray) diff --git a/Queue/Backend.php b/Queue/Backend.php index d637e90..12adfd3 100644 --- a/Queue/Backend.php +++ b/Queue/Backend.php @@ -27,6 +27,8 @@ public function getNumValuesInList($key); public function setIfNotExists($key, $value, $ttlInSeconds); + public function delete($key); + public function deleteIfKeyHasValue($key, $value); public function hasAtLeastXRequestsQueued($key, $numValuesRequired); diff --git a/Queue/Backend/Redis.php b/Queue/Backend/Redis.php index e9d6f9c..06f829b 100644 --- a/Queue/Backend/Redis.php +++ b/Queue/Backend/Redis.php @@ -16,7 +16,7 @@ class Redis implements Backend { /** - * @var \Redis + * @var \Redis|null */ protected $redis; protected $host; @@ -25,7 +25,7 @@ class Redis implements Backend protected $password; /** - * @var int + * @var int|null */ protected $database; @@ -177,9 +177,6 @@ public function getConnection() return $this->redis; } - /** - * @internal for tests only - */ public function delete($key) { $this->connectIfNeeded(); diff --git a/Queue/Backend/RedisCluster.php b/Queue/Backend/RedisCluster.php index a9a4d0e..be0a92f 100644 --- a/Queue/Backend/RedisCluster.php +++ b/Queue/Backend/RedisCluster.php @@ -11,11 +11,13 @@ namespace Piwik\Plugins\QueuedTracking\Queue\Backend; use Piwik\Log; +use Piwik\Piwik; +use Exception; class RedisCluster extends Redis { /** - * @var \RedisCluster + * @var \RedisCluster|null */ protected $redis; protected $host; @@ -89,7 +91,7 @@ public function getMemoryStats() $memory = array ( 'used_memory_human' => 0, - 'used_memory_peak_human' => 0 + 'used_memory_peak_human' => 0, ); foreach ($hosts as $idx => $host) { @@ -209,9 +211,6 @@ public function getConnection() return $this->redis; } - /** - * @internal for tests only - */ public function delete($key) { $this->connectIfNeeded(); @@ -305,7 +304,9 @@ protected function connect() throw new Exception(Piwik::translate('QueuedTracking_NumHostsNotMatchNumPorts')); } - $hostsPorts = array_map(fn($host, $port): string => "$host:$port", $hosts, $ports); + $hostsPorts = array_map(function ($host, $port): string { + return "$host:$port"; + }, $hosts, $ports); try { $this->redis = new \RedisCluster(null, $hostsPorts, $this->timeout, $this->timeout, true, $this->password); diff --git a/Queue/Backend/Sentinel.php b/Queue/Backend/Sentinel.php index fc73d8a..75b9f15 100644 --- a/Queue/Backend/Sentinel.php +++ b/Queue/Backend/Sentinel.php @@ -38,7 +38,7 @@ protected function connect() foreach ($hosts as $index => $host) { // Sort or randomize as appropriate try { - $configuredClient = new \Credis_Client($host, $ports[$index], $timeout = 0.5, $persistent = false); + $configuredClient = new \Credis_Client($host, (int) $ports[$index], $timeout = 0.5, $persistent = ''); $configuredClient->forceStandalone(); $configuredClient->connect(); if ($this->usePasswordForSentinelInstances && !empty($this->password)) { @@ -52,7 +52,7 @@ protected function connect() $this->timeout = 0.05; } - $client = new \Credis_Client($master[0], $master[1], $this->timeout, $persistent = false, $this->database, $this->password); + $client = new \Credis_Client($master[0], $master[1], $this->timeout, $persistent = '', $this->database, $this->password); $client->connect(); $this->redis = $client; diff --git a/Queue/Manager.php b/Queue/Manager.php index c1820cf..6c05ab4 100644 --- a/Queue/Manager.php +++ b/Queue/Manager.php @@ -39,34 +39,6 @@ class Manager private $forceQueueId; - /** - * This mapping makes sure we move requests more evenly into different queues. Eg if we would do a - * ord($firstLetter) instead of this mapping, and have 4 workers, we would move the following characters: - * '0, 4, 8, d' into queue 0, '1, 5, 9, a, e' into queue 1, '2, 6, b, f' into queue 2 and '3, 7, c' into queue 3. - * This means queue 0 would get way more requests assigned compared to queue 3. It won't be perfect this way eg - * when having only 3 workers there will be always one queue with a few more results. - * - * @var array - */ - private $mappingLettersToNumeric = array( - '0' => 0, - '1' => 1, - '2' => 2, - '3' => 3, - '4' => 4, - '5' => 5, - '6' => 6, - '7' => 7, - '8' => 8, - '9' => 9, - 'a' => 10, - 'b' => 11, - 'c' => 12, - 'd' => 13, - 'e' => 14, - 'f' => 15, - ); - public function __construct(Backend $backend, Lock $lock) { $this->backend = $backend; @@ -250,7 +222,7 @@ private function getRandomQueueId() } /** - * @return Queue + * @return Queue|null */ public function lockNext() { @@ -261,12 +233,12 @@ public function lockNext() $shouldProcess = $queue->shouldProcess(); - if ($shouldProcess && $this->lock->acquireLock($this->forceQueueId)) { + if ($shouldProcess && $this->lock->acquireLock((string) $this->forceQueueId)) { return $queue; } // do not try to acquire a different lock - return; + return null; } if ($this->currentQueueId < 0) { @@ -285,10 +257,12 @@ public function lockNext() $shouldProcess = $queue->shouldProcess(); - if ($shouldProcess && $this->lock->acquireLock($this->currentQueueId)) { + if ($shouldProcess && $this->lock->acquireLock((string) $this->currentQueueId)) { return $queue; } } + + return null; } public function unlock() diff --git a/Queue/Processor/Handler.php b/Queue/Processor/Handler.php index 643c272..167ddd6 100644 --- a/Queue/Processor/Handler.php +++ b/Queue/Processor/Handler.php @@ -51,7 +51,7 @@ public function process(Tracker $tracker, RequestSet $requestSet) $diffInMs = round(microtime(true) * 1000) - $startMs; if ($diffInMs > 2000) { - Common::printDebug(sprintf('The following request took more than 2 seconds (%d ms) to be tracked: %s', $diffInMs, var_export($request->getParams(), 1))); + Common::printDebug(sprintf('The following request took more than 2 seconds (%d ms) to be tracked: %s', $diffInMs, var_export($request->getParams(), true))); } $this->count++; diff --git a/SystemSettings.php b/SystemSettings.php index d8722b4..80f43bd 100644 --- a/SystemSettings.php +++ b/SystemSettings.php @@ -68,7 +68,7 @@ public function getAvailableRedisBackendTypes() return array( 1 => Piwik::translate('QueuedTracking_AvailableRedisBackendTypeStandAlone'), 2 => Piwik::translate('QueuedTracking_AvailableRedisBackendTypeSentinel'), - 3 => Piwik::translate('QueuedTracking_AvailableRedisBackendTypeCluster') + 3 => Piwik::translate('QueuedTracking_AvailableRedisBackendTypeCluster'), ); } @@ -277,7 +277,7 @@ private function createQueueEnabledSetting() $systemCheck = new SystemCheck(); - if ($self->isRedisBackend() && !$self->isUsingSentinelBackend()) { + if (!$self->isUsingSentinelBackend()) { $systemCheck->checkRedisIsInstalled(); } $backend = Factory::makeBackendFromSettings($self); diff --git a/Tracker/Handler.php b/Tracker/Handler.php index f90881d..261005c 100644 --- a/Tracker/Handler.php +++ b/Tracker/Handler.php @@ -24,7 +24,7 @@ class Handler extends Tracker\Handler { /** - * @var Backend + * @var Backend|null */ private $backend; diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..2982012 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,141 @@ +parameters: + level: 5 + phpVersion: 70200 + tmpDir: /tmp/phpstan/QueuedTracking/main + paths: + - . + excludePaths: + analyseAndScan: + - tests/* + analyse: + # Vendored Credis. Scanned but not analysed, so Queue\Backend\Redis and its siblings + # still resolve the client's methods while the library keeps its own conventions. + - libs/credis/* + ignoreErrors: + # The bundled ext-redis stubs disagree with the extension this plugin actually runs + # against. Reflected in the environment: RedisCluster::info() is + # (array|string $key_or_address, string $sections = '') returning RedisCluster|array|false, + # flushDB() takes array|string, and config() is (key_or_address, subcommand, ...extra_args) + # with two required arguments. Every call below matches the extension, not the stub. + - + identifier: arguments.count + message: '#RedisCluster::info\(\) invoked with 2 parameters#' + path: Queue/Backend/RedisCluster.php + count: 1 + - + identifier: argument.type + message: '#RedisCluster::info\(\) expects string, array#' + path: Queue/Backend/RedisCluster.php + count: 1 + # The stub's \Redis return types include \Redis itself, which phpredis only returns in + # pipeline or MULTI mode; this backend never enters either. + - + identifier: equal.invalid + path: Queue/Backend/Redis.php + count: 2 + - + identifier: greater.invalid + path: Queue/Backend/Redis.php + count: 1 + - + identifier: arguments.count + message: '#RedisCluster::config\(\) invoked with 3 parameters#' + path: Commands/Test.php + count: 1 + - + identifier: offsetAccess.notFound + message: "#Offset 'redis_version' does not exist on non-falsy-string#" + path: Queue/Backend/RedisCluster.php + count: 1 + - + identifier: nullCoalesce.offset + message: "#Offset 'used_memory(_peak)?' on string on left side of \\?\\? does not exist#" + path: Queue/Backend/RedisCluster.php + count: 2 + - + identifier: argument.type + message: '#RedisCluster::flushDB\(\) expects string, array#' + path: Queue/Backend/RedisCluster.php + count: 1 + # Three backends share one $redis property and one getConnection(), holding \Redis, + # \RedisCluster or Credis_Client depending on how the queue is configured. Credis_Client + # mirrors the phpredis API deliberately, which is what makes the arrangement work at + # runtime; expressing it in the types means restructuring the hierarchy. + - + identifier: property.phpDocType + path: Queue/Backend/RedisCluster.php + count: 1 + - + identifier: method.childReturnType + path: Queue/Backend/RedisCluster.php + count: 1 + - + identifier: assign.propertyType + message: '#does not accept Credis_Client#' + path: Queue/Backend/Sentinel.php + count: 1 + # Db::query() is declared to return a Zend_Db_Statement, but in tracker mode with the + # mysqli adapter it does not — which is exactly what the branches these findings sit on + # were written to handle, as their own comments say. + - + identifier: identical.alwaysFalse + message: '#Zend_Db_Statement and false#' + path: Queue/Backend/MySQL.php + count: 1 + # Db::fetchOne() is documented as returning a string, but it returns null when the column is + # NULL, as max() - min() is over an empty list table, and false when there is no row. + - + identifier: identical.alwaysFalse + message: '#between string and (false|null)#' + path: Queue/Backend/MySQL.php + count: 2 + - + identifier: booleanOr.alwaysFalse + path: Queue/Backend/MySQL.php + count: 1 + # Error has had getMessage() and getCode() and implemented Throwable since PHP 7.0, but the + # PHP 7.2 analysis reports the caught Throwable, once narrowed to Error, as having neither. + - + identifier: method.notFound + message: '#Error::get(Code|Message)\(\)#' + path: Queue/Processor/Handler.php + count: 2 + - + identifier: argument.type + message: '#\$previous of class Exception constructor expects Throwable\|null, Error given#' + path: Queue/Processor/Handler.php + count: 1 + # Matomo 5.0 documents getRequestVar()'s default as string|null although it casts the value + # to the requested type; later releases widen it, so only the minimum leg reports this. + - + identifier: argument.type + message: '#\$varDefault of static method Piwik\\Common::getRequestVar\(\) expects string\|null, int given#' + path: QueuedTracking.php + count: 1 + reportUnmatched: false + # Db::get() is declared as a union whose interface never declares fetchCol(), though both + # implementations it can return do provide it. + - + identifier: method.notFound + message: '#::fetchCol\(\)#' + path: Queue/Backend/MySQL.php + count: 1 + - + identifier: method.notFound + message: '#::fetchCol\(\)#' + path: Tasks.php + count: 1 + # The updater builds every Updates class with the migration factory, as this plugin's two + # other update classes show; this one performs no migrations and so never uses it. + - + identifier: constructor.unusedParameter + path: Updates/5.1.0.php + count: 1 + bootstrapFiles: + - ../../bootstrap-phpstan.php + universalObjectCratesClasses: + - Piwik\Config + - Piwik\View + - Piwik\ViewDataTable\Config + scanDirectories: + - ../../ diff --git a/phpstan/phpstan.created.neon b/phpstan/phpstan.created.neon new file mode 100644 index 0000000..87f6b28 --- /dev/null +++ b/phpstan/phpstan.created.neon @@ -0,0 +1,6 @@ +includes: + - ../phpstan.neon +parameters: + # new files carry no pre-existing debt, so hold them to the strictest level + level: 9 + tmpDir: /tmp/phpstan/QueuedTracking/created diff --git a/phpstan/phpstan.modified.neon b/phpstan/phpstan.modified.neon new file mode 100644 index 0000000..83549d1 --- /dev/null +++ b/phpstan/phpstan.modified.neon @@ -0,0 +1,5 @@ +includes: + - ../phpstan.neon +parameters: + level: 5 + tmpDir: /tmp/phpstan/QueuedTracking/modified diff --git a/tests/Integration/Queue/ManagerTest.php b/tests/Integration/Queue/ManagerTest.php index 7a75ecc..79ba739 100644 --- a/tests/Integration/Queue/ManagerTest.php +++ b/tests/Integration/Queue/ManagerTest.php @@ -144,7 +144,7 @@ public function test_getQueueIdForVisitor_shouldMoveVisitorIntoQueueBasedOnFirst { $numQueues = $this->manager->getNumberOfAvailableQueues(); - $a = 10 % $numQueues; // 10, 11, 12 is a internal mapping see {Manager::$mappingLettersToNumeric} + $a = 10 % $numQueues; // 10, 11, 12 are hexdec() of a, b, c, see Manager::getQueueIdForVisitor() $b = 11 % $numQueues; $c = 12 % $numQueues; $d = 0;